diff --git a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt index a454479..39a475c 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt @@ -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() { 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 150980f..fbd951e 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt @@ -28,8 +28,15 @@ class TrackerRepository(private val db: VigilDatabase) { fun observeTrackers(): Flow> = 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 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 8e87e81..f01c2fe 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 @@ -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") diff --git a/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt b/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt index ba77f78..015a5d2 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/detect/CoMovementEvaluator.kt @@ -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 } diff --git a/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt b/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt index 1e1d0fa..8906b48 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/detect/PresenceEngine.kt @@ -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 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 bfe7dcb..6a0dd95 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/scan/BleTrackerScanner.kt @@ -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()) } } diff --git a/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt b/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt index 27cfac3..dcaf287 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/scan/TrackerParser.kt @@ -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, 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 2ffcaae..6d7aad6 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt @@ -51,6 +51,9 @@ class ScanService : LifecycleService() { 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" @@ -112,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) } } } @@ -153,7 +158,9 @@ 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() @@ -190,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})") } @@ -201,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") } @@ -221,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 } @@ -229,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) + } + ) + } } } 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 d09c382..48aca2e 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -72,7 +72,8 @@ 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(null) } @@ -162,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) } } @@ -191,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 } + ) } } } @@ -286,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( @@ -313,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") } } } @@ -324,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) { @@ -354,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)) }