3 Commits

Author SHA1 Message Date
Kara Zajac f952a8e7ca Release 0.1.1: critical bugfix sweep
Build APK / build (push) Has been cancelled
Ships the two rounds of logical-flaw fixes: the evaluator overflow (never-alerted), silent alert channel, Apple phantom-tracker parser bug, stalker-auto-trust baseline flaw, ingestion throttle, lost-update race, unbounded tracker rows, and RSSI/evidence surfaced in the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:19:22 -04:00
Kara Zajac 98edf4345a 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
2026-07-15 09:14:17 -04:00
Kara Zajac de58944ce7 Bugfix/logical-flaw sweep: counting, races, baseline, pruning, RSSI
Root cause of the non-monotonic sighting count (60->29->63): MAC-rotating ecosystems mint a new tracker row per rotation, tracker rows were never pruned, and the UI re-sorts constantly, so different rows' counts read as one sequence. Compounded by no ingestion throttle and a lost-update race.

Fixes:
- ScanService: throttle the persisted path to <=1 sighting/device/15s (presence engine still sees every advert). Stops count inflation + DB hammering. @Volatile clone-alert ts; clear throttle map on stop.
- TrackerRepository: Mutex around record() (was a lost-update race: concurrent observations did get()->compute->upsert() with no lock). Always compute the co-movement assessment for display.
- VigilDatabase: prune stale non-approved tracker rows (were never deleted); +lastRssi/peakRssi/distinctPlaces/effectiveSightings for grounding; version 2.
- BaselineManager: count DWELL (one visit per 10-min window), not per-sighting. Old per-sighting count let a busy street or chatty tracker mint a fake 'home' in seconds and auto-trust a real stalker — a safety flaw.
- BleTrackerScanner: Apple filter now matches only Find My type 0x12, not every Apple device (iPhones/Watches/AirPods).
- UI: show distinct places + last RSSI on cards; detail sheet shows last/peak RSSI, distinct places, co-movement sightings, adverts logged; Active list filters to devices seen in the last 10 min so rotated identities drop off.
- Tests: CoMovementEvaluatorTest locks dedup, the RSSI gate, and the place/separated thresholds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:00:53 -04:00
12 changed files with 314 additions and 103 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.1.1"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -49,8 +49,10 @@ class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val granted = result.all { it.value }
) { _ ->
// Scanning needs BLE + location; POST_NOTIFICATIONS is optional and must not
// block protection if the user declines it.
val granted = hasEssentialPermissions()
permissionsGranted.value = granted
if (granted) ScanService.start(this)
}
@@ -59,7 +61,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
settings = Settings.get(this)
repo = TrackerRepository(VigilDatabase.get(this))
permissionsGranted.value = hasAllPermissions()
permissionsGranted.value = hasEssentialPermissions()
setContent {
VigilTheme {
@@ -86,6 +88,9 @@ class MainActivity : ComponentActivity() {
onSetSensitivity = { settings.setSensitivity(it) },
onApprove = { id, approved ->
lifecycleScope.launch { repo.setApproved(id, approved) }
},
onDistrust = { id ->
lifecycleScope.launch { repo.clearBaseline(id) }
}
)
}
@@ -94,12 +99,12 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
permissionsGranted.value = hasAllPermissions()
permissionsGranted.value = hasEssentialPermissions()
}
private fun hasAllPermissions(): Boolean = requiredPermissions.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
private fun hasEssentialPermissions(): Boolean = requiredPermissions
.filter { it != Manifest.permission.POST_NOTIFICATIONS }
.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
@Suppress("unused")
private fun openAppSettings() {
@@ -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,15 +20,28 @@ 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<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) {
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 +53,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
@@ -54,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
@@ -61,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
@@ -70,19 +97,10 @@ 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
// --- 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
}
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 +118,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 {
@@ -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,16 @@ 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")
suspend fun pruneStale(cutoff: Long)
}
@Dao
@@ -99,7 +114,7 @@ interface PlaceDao {
@Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 1,
version = 2,
exportSchema = false
)
abstract class VigilDatabase : RoomDatabase() {
@@ -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
}
}
@@ -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
@@ -45,11 +45,22 @@ class BleTrackerScanner(
.build()
private val filters: List<ScanFilter> = buildList {
// Apple Find My — match presence of Apple manufacturer data.
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build())
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
// 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 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,
@@ -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,10 @@ 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
private const val ALERT_CHANNEL_ID = "vigil_alerts"
private const val ALERT_NOTIF_ID = 0x5162
private const val CLONE_NOTIF_ID = 0x5163
const val ACTION_START = "org.soulstone.vigil.action.START"
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
@@ -75,7 +80,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<String, Long>()
@Volatile private var lastCloneAlertMs = 0L
private var pruneJob: Job? = null
override fun onCreate() {
@@ -109,8 +115,10 @@ class ScanService : LifecycleService() {
pruneJob?.cancel()
pruneJob = lifecycleScope.launch {
while (true) {
delay(PRUNE_INTERVAL_MS)
// Prune first (frequent restarts previously meant the 6h-delayed
// prune rarely ran, so stale rotated identities accumulated).
runCatching { repo.prune() }.onFailure { Log.w(TAG, "prune failed: ${it.message}") }
delay(PRUNE_INTERVAL_MS)
}
}
}
@@ -131,7 +139,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)
@@ -141,10 +158,13 @@ class ScanService : LifecycleService() {
}
private fun end() {
if (!_running.value) return
// No early-return guard: all stops below are idempotent, and begin() calls
// end() on a failed scanner start (when _running was never set) to make sure
// the location updates and foreground notification are released, not leaked.
_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)
@@ -177,9 +197,9 @@ class ScanService : LifecycleService() {
val n = buildNotification(
title = "Tracker following you",
text = "${tracker.label} has been moving with you",
high = true
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, n)
getSystemService(NotificationManager::class.java)?.notify(ALERT_NOTIF_ID, n)
vibrate()
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
}
@@ -188,9 +208,9 @@ class ScanService : LifecycleService() {
val n = buildNotification(
title = "Possible hidden tracker",
text = "A rotating-ID tracker appears to be moving with you",
high = true
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID + 1, n)
getSystemService(NotificationManager::class.java)?.notify(CLONE_NOTIF_ID, n)
vibrate()
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
}
@@ -208,7 +228,13 @@ class ScanService : LifecycleService() {
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun buildNotification(title: String, text: String, high: Boolean): Notification {
private fun buildNotification(
title: String,
text: String,
high: Boolean,
channelId: String = CHANNEL_ID,
ongoing: Boolean = true
): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@@ -216,31 +242,48 @@ class ScanService : LifecycleService() {
this, 0, openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
return NotificationCompat.Builder(this, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.setOngoing(ongoing)
.setAutoCancel(!ongoing)
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setCategory(if (high) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_SERVICE)
.setPriority(if (high) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(!high)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java) ?: return
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
)
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_desc)
setShowBadge(false)
}
)
}
// Alerts get their OWN high-importance channel so a real "following you"
// warning actually makes noise + a heads-up banner. On Android 8+ channel
// importance overrides per-notification priority, so the ongoing LOW status
// channel could never sound an alert.
if (mgr.getNotificationChannel(ALERT_CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(
ALERT_CHANNEL_ID,
"Tracker alerts",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "A tracker appears to be following you"
enableVibration(true)
}
)
}
}
}
@@ -72,11 +72,15 @@ fun MainScreen(
permissionMessage: String?,
onStartStop: () -> Unit,
onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
) {
var detail by remember { mutableStateOf<TrackerEntity?>(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 }
@@ -159,14 +163,14 @@ fun MainScreen(
}
} else {
items(active, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
}
}
if (trusted.isNotEmpty()) {
item { SectionHeader("Trusted (${trusted.size})") }
items(trusted, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
}
}
@@ -188,7 +192,11 @@ fun MainScreen(
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
TrackerDetail(t, onApprove = { id, a -> onApprove(id, a); detail = null })
TrackerDetail(
t,
onApprove = { id, a -> onApprove(id, a); detail = null },
onDistrust = { id -> onDistrust(id); detail = null }
)
}
}
}
@@ -283,7 +291,12 @@ private fun SectionHeader(text: String) {
}
@Composable
private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (String, Boolean) -> Unit) {
private fun TrackerCard(
t: TrackerEntity,
onClick: () -> Unit,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
) {
val status = statusOf(t)
val accent = statusColor(status)
Card(
@@ -302,7 +315,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
)
@@ -310,10 +323,8 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
when (status) {
TrackerStatus.SAFE_APPROVED ->
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
TrackerStatus.SAFE_BASELINE -> Icon(
Icons.Filled.Verified, null,
tint = VigilGreen, modifier = Modifier.size(20.dp)
)
TrackerStatus.SAFE_BASELINE ->
TextButton(onClick = { onDistrust(t.stableId) }) { Text("Not mine") }
else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's mine") }
}
}
@@ -321,7 +332,11 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
}
@Composable
private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) {
private fun TrackerDetail(
t: TrackerEntity,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -333,11 +348,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) + "")
@@ -348,14 +366,19 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(20.dp))
if (status == TrackerStatus.SAFE_APPROVED) {
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
Text("Remove from approved")
}
} else if (status != TrackerStatus.SAFE_BASELINE) {
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
Text("This is mine — stop alerting")
}
when (status) {
TrackerStatus.SAFE_APPROVED ->
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
Text("Remove from approved")
}
TrackerStatus.SAFE_BASELINE ->
Button(onClick = { onDistrust(t.stableId) }, modifier = Modifier.fillMaxWidth()) {
Text("Not mine — re-check this tracker")
}
else ->
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
Text("This is mine — stop alerting")
}
}
Spacer(Modifier.height(24.dp))
}
@@ -371,6 +394,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)) {
@@ -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)
}
}