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:
@@ -49,8 +49,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
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
|
permissionsGranted.value = granted
|
||||||
if (granted) ScanService.start(this)
|
if (granted) ScanService.start(this)
|
||||||
}
|
}
|
||||||
@@ -59,7 +61,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
settings = Settings.get(this)
|
settings = Settings.get(this)
|
||||||
repo = TrackerRepository(VigilDatabase.get(this))
|
repo = TrackerRepository(VigilDatabase.get(this))
|
||||||
permissionsGranted.value = hasAllPermissions()
|
permissionsGranted.value = hasEssentialPermissions()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
VigilTheme {
|
VigilTheme {
|
||||||
@@ -86,6 +88,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
onSetSensitivity = { settings.setSensitivity(it) },
|
onSetSensitivity = { settings.setSensitivity(it) },
|
||||||
onApprove = { id, approved ->
|
onApprove = { id, approved ->
|
||||||
lifecycleScope.launch { repo.setApproved(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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
permissionsGranted.value = hasAllPermissions()
|
permissionsGranted.value = hasEssentialPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasAllPermissions(): Boolean = requiredPermissions.all {
|
private fun hasEssentialPermissions(): Boolean = requiredPermissions
|
||||||
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
.filter { it != Manifest.permission.POST_NOTIFICATIONS }
|
||||||
}
|
.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
private fun openAppSettings() {
|
private fun openAppSettings() {
|
||||||
|
|||||||
@@ -28,8 +28,15 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
|
|
||||||
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
|
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)
|
db.trackerDao().setApproved(id, approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearBaseline(id: String) = mutex.withLock {
|
||||||
|
db.trackerDao().clearBaseline(id)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
|
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
|
||||||
val cutoff = System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS
|
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 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 -------------------
|
// --- baseline: learn tags that live where you live -------------------
|
||||||
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
|
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
|
||||||
@@ -69,7 +85,10 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
var baselineSafe = existing?.baselineSafe ?: false
|
var baselineSafe = existing?.baselineSafe ?: false
|
||||||
if (geohash6 != null) {
|
if (geohash6 != null) {
|
||||||
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
|
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
|
val day = now / BaselineManager.DAY_MS
|
||||||
if (day != lastAnchorDay) {
|
if (day != lastAnchorDay) {
|
||||||
lastAnchorDay = day
|
lastAnchorDay = day
|
||||||
@@ -78,15 +97,9 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
|
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 riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
|
||||||
|
|
||||||
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
|
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ interface TrackerDao {
|
|||||||
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
|
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
|
||||||
suspend fun setApproved(id: String, approved: Boolean)
|
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
|
// 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.
|
// 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")
|
@Query("DELETE FROM trackers WHERE lastSeen < :cutoff AND approved = 0")
|
||||||
|
|||||||
@@ -59,11 +59,14 @@ object CoMovementEvaluator {
|
|||||||
val sorted = sightings.sortedBy { it.timestamp }
|
val sorted = sightings.sortedBy { it.timestamp }
|
||||||
|
|
||||||
// Debounce: one effective sighting per DEDUP_MS so a chatty tag at 2s
|
// 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 effective = 0
|
||||||
var lastCounted = Long.MIN_VALUE
|
var lastCounted: Long? = null
|
||||||
for (s in sorted) {
|
for (s in sorted) {
|
||||||
if (s.timestamp - lastCounted >= DEDUP_MS) {
|
if (lastCounted == null || s.timestamp - lastCounted >= DEDUP_MS) {
|
||||||
effective++
|
effective++
|
||||||
lastCounted = s.timestamp
|
lastCounted = s.timestamp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,8 +137,11 @@ class PresenceEngine {
|
|||||||
score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20
|
score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20
|
||||||
score += (if (coherent) 1.0 else 0.4) * 20
|
score += (if (coherent) 1.0 else 0.4) * 20
|
||||||
val s = score.roundToInt()
|
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 {
|
val tier = when {
|
||||||
s >= 85 -> Tier.CONFIRMED
|
s >= 85 && coherent -> Tier.CONFIRMED
|
||||||
s >= 70 -> Tier.PROBABLE
|
s >= 70 -> Tier.PROBABLE
|
||||||
s >= 40 -> Tier.WATCHING
|
s >= 40 -> Tier.WATCHING
|
||||||
else -> Tier.CLEAR
|
else -> Tier.CLEAR
|
||||||
|
|||||||
@@ -55,9 +55,12 @@ class BleTrackerScanner(
|
|||||||
byteArrayOf(0xFF.toByte())
|
byteArrayOf(0xFF.toByte())
|
||||||
).build()
|
).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) {
|
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) ---
|
// --- Apple Find My (manufacturer data, company 0x004C) ---
|
||||||
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
|
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
|
||||||
if (d.isNotEmpty() && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
|
// Require the FULL offline-finding frame. After Android strips the company
|
||||||
// Android strips the 2-byte company id, so d = [type(0x12),
|
// id, d = [type, len, status, key(22), keyTopBits, hint] (~27 bytes). Short
|
||||||
// length(0x19), status, key(22), keyTopBits, hint]. The maintained
|
// "nearby" frames carry no key — ignore them rather than fabricate a shared
|
||||||
// bit lives in the status byte (index 2): set => near owner,
|
// identity from the type/status bytes, which merged many distinct devices
|
||||||
// cleared/absent => treat as separated.
|
// into one phantom "tracker" and misclassified it as separated.
|
||||||
val status = if (d.size > 2) d[2].toInt() and 0xFF else 0
|
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)
|
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
|
||||||
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
|
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(
|
return TrackerObservation(
|
||||||
stableId = "apple:" + toHex(keyBytes),
|
stableId = "apple:" + toHex(keyBytes),
|
||||||
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
|
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class ScanService : LifecycleService() {
|
|||||||
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
|
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
|
||||||
private const val CLONE_COOLDOWN_MS = 2 * 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 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_START = "org.soulstone.vigil.action.START"
|
||||||
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
|
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
|
||||||
@@ -112,8 +115,10 @@ class ScanService : LifecycleService() {
|
|||||||
pruneJob?.cancel()
|
pruneJob?.cancel()
|
||||||
pruneJob = lifecycleScope.launch {
|
pruneJob = lifecycleScope.launch {
|
||||||
while (true) {
|
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}") }
|
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() {
|
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
|
_running.value = false
|
||||||
scanner.stop()
|
scanner.stop()
|
||||||
location.stop()
|
location.stop()
|
||||||
@@ -190,9 +197,9 @@ class ScanService : LifecycleService() {
|
|||||||
val n = buildNotification(
|
val n = buildNotification(
|
||||||
title = "Tracker following you",
|
title = "Tracker following you",
|
||||||
text = "${tracker.label} has been moving with 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()
|
vibrate()
|
||||||
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
|
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
|
||||||
}
|
}
|
||||||
@@ -201,9 +208,9 @@ class ScanService : LifecycleService() {
|
|||||||
val n = buildNotification(
|
val n = buildNotification(
|
||||||
title = "Possible hidden tracker",
|
title = "Possible hidden tracker",
|
||||||
text = "A rotating-ID tracker appears to be moving with you",
|
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()
|
vibrate()
|
||||||
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
|
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
|
||||||
}
|
}
|
||||||
@@ -221,7 +228,13 @@ class ScanService : LifecycleService() {
|
|||||||
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
@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 {
|
val openIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
@@ -229,22 +242,22 @@ class ScanService : LifecycleService() {
|
|||||||
this, 0, openIntent,
|
this, 0, openIntent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, channelId)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||||
.setOngoing(true)
|
.setOngoing(ongoing)
|
||||||
|
.setAutoCancel(!ongoing)
|
||||||
.setContentIntent(pi)
|
.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)
|
.setPriority(if (high) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW)
|
||||||
.setOnlyAlertOnce(!high)
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val mgr = getSystemService(NotificationManager::class.java) ?: return
|
val mgr = getSystemService(NotificationManager::class.java) ?: return
|
||||||
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
|
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
@@ -256,4 +269,21 @@ class ScanService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 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,7 +72,8 @@ fun MainScreen(
|
|||||||
permissionMessage: String?,
|
permissionMessage: String?,
|
||||||
onStartStop: () -> Unit,
|
onStartStop: () -> Unit,
|
||||||
onSetSensitivity: (Sensitivity) -> Unit,
|
onSetSensitivity: (Sensitivity) -> Unit,
|
||||||
onApprove: (String, Boolean) -> Unit
|
onApprove: (String, Boolean) -> Unit,
|
||||||
|
onDistrust: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
|
|
||||||
@@ -162,14 +163,14 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(active, key = { it.stableId }) { t ->
|
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()) {
|
if (trusted.isNotEmpty()) {
|
||||||
item { SectionHeader("Trusted (${trusted.size})") }
|
item { SectionHeader("Trusted (${trusted.size})") }
|
||||||
items(trusted, key = { it.stableId }) { t ->
|
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,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
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
|
@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 status = statusOf(t)
|
||||||
val accent = statusColor(status)
|
val accent = statusColor(status)
|
||||||
Card(
|
Card(
|
||||||
@@ -313,10 +323,8 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
|
|||||||
when (status) {
|
when (status) {
|
||||||
TrackerStatus.SAFE_APPROVED ->
|
TrackerStatus.SAFE_APPROVED ->
|
||||||
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
|
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
|
||||||
TrackerStatus.SAFE_BASELINE -> Icon(
|
TrackerStatus.SAFE_BASELINE ->
|
||||||
Icons.Filled.Verified, null,
|
TextButton(onClick = { onDistrust(t.stableId) }) { Text("Not mine") }
|
||||||
tint = VigilGreen, modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's 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
|
@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)
|
val status = statusOf(t)
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -354,11 +366,16 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
if (status == TrackerStatus.SAFE_APPROVED) {
|
when (status) {
|
||||||
|
TrackerStatus.SAFE_APPROVED ->
|
||||||
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Remove from approved")
|
Text("Remove from approved")
|
||||||
}
|
}
|
||||||
} else if (status != TrackerStatus.SAFE_BASELINE) {
|
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()) {
|
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("This is mine — stop alerting")
|
Text("This is mine — stop alerting")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user