6 Commits

Author SHA1 Message Date
Kara Zajac a2f293a486 Release 0.1.2: Find it (hot/cold) + Make it ring
Build APK / build (push) Has been cancelled
Adds the passive RSSI hot/cold finder and user-initiated GATT play-sound (AirTag/DULT/Find My; Samsung/Tile point to their apps). README notes ringing as the one active operation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:53:40 -04:00
Kara Zajac 4af3a2380a Fix Surface import; implement real per-ecosystem GATT ring protocols
- MainScreen: add missing Surface import (polished screen switched to Scaffold and dropped it; FinderScreen uses Surface) — was the compile break.
- TrackerRinger: real multi-protocol play-sound from AirGuard's verified source: AirTag native (single 0xAF byte, self-disconnects = success via status 19), DULT (0x0300, enable indications first), Find My legacy fd44 (enable notifications, [0x01,0x00,0x03]); tried in priority order by which service the tag exposes. Samsung SmartTag + Tile have no non-owner ring path -> tell the user to use SmartThings / the Tile app. API-version-aware characteristic + descriptor writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:47:59 -04:00
Kara Zajac 0efc205220 Add 'Find it' hot/cold RSSI finder + 'Make it ring' (GATT)
- FinderScreen: live smoothed RSSI as a growing, colour-shifting proximity meter (works on silent/modified tags that refuse to ring). Fed by ScanService.finderRssi (every advert, ahead of the DB throttle).
- TrackerRinger: user-initiated GATT play-sound — VIGIL's one active operation (detection stays passive). DULT cross-vendor path (Google FMDN + DULT partners) implemented; Apple/others report gracefully pending per-ecosystem UUIDs (GATT protocol research in progress). UUIDs/opcode pending on-device verification.
- Detail sheet: 'Find it' + 'Ring it' buttons; TrackerEntity.lastMac added (needed to GATT-connect); DB version 3.
- BLUETOOTH_CONNECT was already declared for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:43:00 -04:00
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
14 changed files with 654 additions and 103 deletions
+16 -2
View File
@@ -26,8 +26,11 @@ that idea.
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account, VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
no telemetry. Every tracker, every sighting, and the entire learned baseline live no telemetry. Every tracker, every sighting, and the entire learned baseline live
in an on-device SQLite database and never leave the phone. It listens only — it in an on-device SQLite database and never leave the phone. **Detection is entirely
never transmits, probes, or interferes with any device. passive — it listens only.** The one exception is the user-initiated **"Make it
ring"** action, which connects to a tracker you already suspect and asks it to play
a sound (the DULT-standard way for a victim to locate a hidden tag); nothing is
transmitted unless you tap it.
--- ---
@@ -71,6 +74,17 @@ Two trust signals suppress false alarms:
auto-marked **Known (home)**. So the household tags that are always around you auto-marked **Known (home)**. So the household tags that are always around you
fall silent on their own, entirely on-device. fall silent on their own, entirely on-device.
## Finding a tracker
Tap any tracker for two ways to physically locate it:
- **Make it ring** — connects over GATT and plays the tracker's own sound. Works for
AirTags (native `0xAF` sound), Google Find My Device, and DULT tags (Chipolo,
Pebblebee, eufy, Motorola). Samsung SmartTag and Tile expose no non-owner ring, so
VIGIL points you to the SmartThings / Tile app instead.
- **Hot/cold finder** — a passive proximity meter that turns live signal strength into
a warmer/colder readout. It still works on **silent or modified tags that refuse to
ring**, which is exactly when you need it most.
## The hard part — catching clones (problem #1) ## The hard part — catching clones (problem #1)
Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil" applicationId = "org.soulstone.vigil"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 3
versionName = "0.1.0" versionName = "0.1.2"
} }
// Fixed debug keystore committed to the repo (a debug key is non-secret — its // Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings as AndroidSettings import android.provider.Settings as AndroidSettings
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -17,7 +18,9 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.vigil.data.TrackerRepository import org.soulstone.vigil.data.TrackerRepository
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase import org.soulstone.vigil.data.db.VigilDatabase
import org.soulstone.vigil.ring.TrackerRinger
import org.soulstone.vigil.data.settings.Settings import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.service.ScanService import org.soulstone.vigil.service.ScanService
import org.soulstone.vigil.ui.MainScreen import org.soulstone.vigil.ui.MainScreen
@@ -49,8 +52,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 +64,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,7 +91,11 @@ 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) }
},
onRing = { tracker -> ringTracker(tracker) }
) )
} }
} }
@@ -94,11 +103,19 @@ 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 }
private fun ringTracker(tracker: TrackerEntity) {
Toast.makeText(this, "Trying to ring ${tracker.label}", Toast.LENGTH_SHORT).show()
lifecycleScope.launch {
val msg = TrackerRinger.ring(this@MainActivity, tracker.lastMac, tracker.ecosystem)
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show()
}
} }
@Suppress("unused") @Suppress("unused")
@@ -1,6 +1,8 @@
package org.soulstone.vigil.data package org.soulstone.vigil.data
import kotlinx.coroutines.flow.Flow 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.SightingEntity
import org.soulstone.vigil.data.db.TrackerEntity import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase import org.soulstone.vigil.data.db.VigilDatabase
@@ -18,15 +20,28 @@ import org.soulstone.vigil.util.Geohash
*/ */
class TrackerRepository(private val db: VigilDatabase) { 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) data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
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) {
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?, lat: Double?,
lon: Double?, lon: Double?,
sensitivity: Sensitivity sensitivity: Sensitivity
): RecordResult { ): RecordResult = mutex.withLock {
val now = obs.timestampMs val now = obs.timestampMs
val geohash7 = if (lat != null && lon != null) Geohash.encode(lat, lon, 7) else null 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 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 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
@@ -61,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
@@ -70,19 +97,10 @@ 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 val riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
// --- 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 wasAlerting = existing?.riskState == RiskState.ALERTING.name val wasAlerting = existing?.riskState == RiskState.ALERTING.name
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
@@ -100,10 +118,15 @@ class TrackerRepository(private val db: VigilDatabase) {
baselineSafe = baselineSafe, baselineSafe = baselineSafe,
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0), lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
lastAnchorDay = lastAnchorDay, lastAnchorDay = lastAnchorDay,
anchorDayCount = anchorDayCount anchorDayCount = anchorDayCount,
lastRssi = obs.rssi,
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
distinctPlaces = assessment.distinctPlaces,
effectiveSightings = assessment.sightings,
lastMac = obs.mac
) )
db.trackerDao().upsert(updated) db.trackerDao().upsert(updated)
return RecordResult(updated, newlyAlerting) RecordResult(updated, newlyAlerting)
} }
companion object { companion object {
@@ -32,7 +32,14 @@ data class TrackerEntity(
val lastAlertMs: Long = 0, val lastAlertMs: Long = 0,
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place // baseline accounting: distinct calendar-days this tracker was seen at an anchor place
val lastAnchorDay: Long = -1, 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,
// last-seen BLE MAC — needed to GATT-connect for "make it ring"
val lastMac: String = ""
) )
/** One sighting of a tracker at one instant, geotagged when a fix is available. */ /** One sighting of a tracker at one instant, geotagged when a fix is available. */
@@ -74,6 +81,16 @@ 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
// 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 @Dao
@@ -99,7 +116,7 @@ interface PlaceDao {
@Database( @Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class], entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 1, version = 3,
exportSchema = false exportSchema = false
) )
abstract class VigilDatabase : RoomDatabase() { abstract class VigilDatabase : RoomDatabase() {
@@ -18,23 +18,29 @@ import org.soulstone.vigil.data.db.PlaceEntity
object BaselineManager { object BaselineManager {
const val DAY_MS = 86_400_000L 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 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 { suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
val existing = placeDao.get(geohash6) val existing = placeDao.get(geohash6) ?: run {
val visits = (existing?.visitCount ?: 0) + 1 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 val anchor = visits >= ANCHOR_MIN_VISITS
placeDao.upsert( placeDao.upsert(existing.copy(visitCount = visits, lastSeen = now, anchor = anchor))
PlaceEntity(
geohash6 = geohash6,
label = existing?.label ?: "",
visitCount = visits,
lastSeen = now,
anchor = anchor
)
)
return anchor return anchor
} }
} }
@@ -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
@@ -0,0 +1,190 @@
package org.soulstone.vigil.ring
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.soulstone.vigil.model.TrackerEcosystem
import java.util.UUID
import kotlin.coroutines.resume
/**
* User-initiated "make it ring" over BLE GATT — VIGIL's ONE active operation
* (detection stays fully passive). Connects to a separated tracker and writes the
* play-sound command, then disconnects. No pairing/bonding is used (non-owner sound
* needs none), matching AirGuard.
*
* Protocols (from AirGuard's AppleFindMy.kt + the IETF DULT draft, tried in order by
* whichever service the tag exposes):
* - AirTag native: write a single 0xAF byte, no CCCD; the tag rings and self-disconnects.
* - DULT (Google FMDN + Chipolo/Pebblebee/eufy/Motorola): enable indications, write 0x0300.
* - Find My legacy (fd44): enable notifications, write [0x01,0x00,0x03].
* Samsung SmartTag and Tile expose NO non-owner ring — the app tells the user to use
* the vendor app instead.
*/
object TrackerRinger {
private const val TAG = "TrackerRinger"
private const val TIMEOUT_MS = 15_000L
private const val STATUS_PEER_DISCONNECT = 19 // GATT_CONN_TERMINATE_PEER_USER — AirTag "done ringing"
private data class Proto(
val name: String,
val service: UUID,
val characteristic: UUID,
val start: ByteArray,
val cccd: Boolean,
val indicate: Boolean
)
private val AIRTAG = Proto(
"AirTag", uuid("7DFC9000-7D1C-4951-86AA-8D9728F8D66C"),
uuid("7DFC9001-7D1C-4951-86AA-8D9728F8D66C"), byteArrayOf(0xAF.toByte()), cccd = false, indicate = false
)
private val DULT = Proto(
"DULT", uuid("15190001-12F4-C226-88ED-2AC5579F2A85"),
uuid("8E0C0001-1D68-FB92-BF61-48377421680E"), byteArrayOf(0x00, 0x03), cccd = true, indicate = true
)
private val FINDMY = Proto(
"FindMy", uuid("0000FD44-0000-1000-8000-00805F9B34FB"),
uuid("4F860003-943B-49EF-BED4-2F730304427A"), byteArrayOf(0x01, 0x00, 0x03), cccd = true, indicate = false
)
private val PRIORITY = listOf(AIRTAG, DULT, FINDMY)
private val CCCD = uuid("00002902-0000-1000-8000-00805F9B34FB")
suspend fun ring(context: Context, mac: String, ecosystem: String): String {
when (runCatching { TrackerEcosystem.valueOf(ecosystem) }.getOrNull()) {
TrackerEcosystem.SAMSUNG_SMARTTAG ->
return "Samsung SmartTags can only be rung from the SmartThings app (owner-only)."
TrackerEcosystem.TILE ->
return "Tiles can only be rung from the Tile app (owner-only)."
else -> {}
}
if (mac.isBlank()) return "No recent address for this tracker — keep watching and try again."
if (!hasConnectPermission(context)) return "Grant Bluetooth (Connect) to ring trackers."
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
?: return "Bluetooth unavailable."
if (!adapter.isEnabled) return "Turn on Bluetooth to ring trackers."
val device = try {
adapter.getRemoteDevice(mac)
} catch (e: IllegalArgumentException) {
return "Invalid device address."
}
return try {
withTimeout(TIMEOUT_MS) { attemptRing(context, device) }
} catch (e: TimeoutCancellationException) {
"Couldn't reach the tracker — it may be out of range, or a silent/modified tag that ignores ring commands."
} catch (e: Exception) {
Log.w(TAG, "ring failed", e)
"Ring failed: ${e.message}"
}
}
@SuppressLint("MissingPermission")
private suspend fun attemptRing(context: Context, device: BluetoothDevice): String =
suspendCancellableCoroutine { cont ->
var gatt: BluetoothGatt? = null
var selected: Proto? = null
fun done(msg: String, g: BluetoothGatt) {
if (cont.isActive) cont.resume(msg)
g.disconnect()
}
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
// An AirTag self-disconnects (status 19) once it has started ringing.
if (cont.isActive) {
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
cont.resume("Ringing… listen for the AirTag.")
else cont.resume("Tracker disconnected before it could ring.")
}
g.close()
}
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
?: return done("This tracker type doesn't expose a remote-ring service.", g)
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g)
selected = proto
if (proto.cccd) {
g.setCharacteristicNotification(ch, true)
val desc = ch.getDescriptor(CCCD)
val v = if (proto.indicate) BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
} else {
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
}
}
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
val proto = selected ?: return
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g)
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
}
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
// AirTag reports via self-disconnect; others confirm here.
if (selected == AIRTAG) {
done("Ringing… listen for the AirTag.", g)
} else {
done(
if (status == BluetoothGatt.GATT_SUCCESS) "Ringing… listen for the tracker."
else "The tracker refused the ring command.", g
)
}
}
}
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } }
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
private fun writeStart(g: BluetoothGatt, ch: BluetoothGattCharacteristic, proto: Proto): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(ch, proto.start, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothGatt.GATT_SUCCESS
} else {
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
ch.value = proto.start
g.writeCharacteristic(ch)
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
private fun writeDescriptor(g: BluetoothGatt, desc: BluetoothGattDescriptor, value: ByteArray): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeDescriptor(desc, value) == BluetoothGatt.GATT_SUCCESS
} else {
desc.value = value
g.writeDescriptor(desc)
}
private fun hasConnectPermission(context: Context): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
} else true
private fun uuid(s: String): UUID = UUID.fromString(s)
}
@@ -45,11 +45,22 @@ class BleTrackerScanner(
.build() .build()
private val filters: List<ScanFilter> = buildList { private val filters: List<ScanFilter> = buildList {
// Apple Find My — match presence of Apple manufacturer data. // Apple Find My — match ONLY the offline-finding message type (0x12), not
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build()) // every Apple device (iPhones/Watches/AirPods all advertise company 0x004C).
// FMDN / Samsung / Tile / DULT — match their service UUIDs. // 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) { 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,
@@ -12,6 +12,7 @@ import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
import android.util.Log import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -49,6 +50,10 @@ class ScanService : LifecycleService() {
private const val NOTIFICATION_ID = 0x5161 // "VIGIL" private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
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 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"
@@ -56,6 +61,16 @@ class ScanService : LifecycleService() {
private val _running = MutableStateFlow(false) private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow() val running: StateFlow<Boolean> = _running.asStateFlow()
// Live RSSI stream for the "find it" hot/cold screen. The UI sets a target
// stableId; every matching advert (not the throttled DB path) updates this.
@Volatile private var finderTarget: String? = null
private val _finderRssi = MutableStateFlow<Int?>(null)
val finderRssi: StateFlow<Int?> = _finderRssi.asStateFlow()
fun setFinderTarget(id: String?) {
finderTarget = id
_finderRssi.value = null
}
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START } val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -75,7 +90,8 @@ class ScanService : LifecycleService() {
private lateinit var location: LocationProvider private lateinit var location: LocationProvider
private lateinit var scanner: BleTrackerScanner private lateinit var scanner: BleTrackerScanner
private val presence = PresenceEngine() private val presence = PresenceEngine()
private var lastCloneAlertMs = 0L private val lastRecordedAt = ConcurrentHashMap<String, Long>()
@Volatile private var lastCloneAlertMs = 0L
private var pruneJob: Job? = null private var pruneJob: Job? = null
override fun onCreate() { override fun onCreate() {
@@ -109,8 +125,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)
} }
} }
} }
@@ -118,6 +136,9 @@ class ScanService : LifecycleService() {
private fun onObservation(obs: TrackerObservation) { private fun onObservation(obs: TrackerObservation) {
val fix = location.location.value val fix = location.location.value
// Live RSSI for the "find it" screen — every advert, ahead of any throttle.
if (obs.stableId == finderTarget) _finderRssi.value = obs.rssi
// Rotation-clone presence engine (problem #1) — fed synchronously so its // Rotation-clone presence engine (problem #1) — fed synchronously so its
// streaming state stays ordered. Only a CONFIRMED verdict raises a user // streaming state stays ordered. Only a CONFIRMED verdict raises a user
// alert; softer tiers stay silent until validated on real captures. // alert; softer tiers stay silent until validated on real captures.
@@ -131,7 +152,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 { lifecycleScope.launch {
val result = runCatching { val result = runCatching {
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value) repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
@@ -141,10 +171,13 @@ 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()
lastRecordedAt.clear()
pruneJob?.cancel(); pruneJob = null pruneJob?.cancel(); pruneJob = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -177,9 +210,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})")
} }
@@ -188,9 +221,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")
} }
@@ -208,7 +241,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
} }
@@ -216,31 +255,48 @@ 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,
getString(R.string.notification_channel_name), getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = getString(R.string.notification_channel_desc) description = getString(R.string.notification_channel_desc)
setShowBadge(false) 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)
}
)
}
} }
} }
@@ -1,6 +1,14 @@
package org.soulstone.vigil.ui package org.soulstone.vigil.ui
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material3.FilledTonalButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.lerp
import org.soulstone.vigil.service.ScanService
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -36,6 +44,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -72,11 +81,17 @@ 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,
onRing: (TrackerEntity) -> Unit
) { ) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) } var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding 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 trusted = trackers.filter { isTrusted(it) }
val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING } val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS } val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
@@ -159,14 +174,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)
} }
} }
@@ -188,9 +203,23 @@ 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 },
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
onRing = onRing
)
} }
} }
finding?.let { dev ->
FinderScreen(
dev,
onRing = onRing,
onClose = { ScanService.setFinderTarget(null); finding = null }
)
}
} }
@Composable @Composable
@@ -283,7 +312,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(
@@ -302,7 +336,7 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold) Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
Text( Text(
"${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}", "${statusLabel(status)} · ${t.distinctPlaces} places · ${t.lastRssi} dBm · ${relative(t.lastSeen)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -310,10 +344,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") }
} }
} }
@@ -321,7 +353,13 @@ 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,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> 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) {
@@ -333,11 +371,14 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold) Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(16.dp)) 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("First seen", relative(t.firstSeen))
DetailRow("Last seen", relative(t.lastSeen)) DetailRow("Last seen", relative(t.lastSeen))
if (t.anchorDayCount > 0) { 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) + "") DetailRow("Identity", t.stableId.take(22) + "")
@@ -348,15 +389,33 @@ 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) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) { FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
Text("Remove from approved") Icon(Icons.Filled.Sensors, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Find it")
} }
} else if (status != TrackerStatus.SAFE_BASELINE) { FilledTonalButton(onClick = { onRing(t) }, modifier = Modifier.weight(1f)) {
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Filled.NotificationsActive, null, modifier = Modifier.size(18.dp))
Text("This is mine — stop alerting") Spacer(Modifier.size(6.dp))
Text("Ring it")
} }
} }
Spacer(Modifier.height(12.dp))
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)) Spacer(Modifier.height(24.dp))
} }
} }
@@ -369,8 +428,82 @@ private fun DetailRow(label: String, value: String) {
} }
} }
/** Passive hot/cold finder — smoothed live RSSI as a growing, colour-shifting
* proximity meter. Works even on silent/modified tags that refuse to ring. */
@Composable
private fun FinderScreen(t: TrackerEntity, onRing: (TrackerEntity) -> Unit, onClose: () -> Unit) {
val rssi by ScanService.finderRssi.collectAsState()
val smoothed = remember { mutableStateOf(-100f) }
LaunchedEffect(rssi) { rssi?.let { smoothed.value = 0.35f * it + 0.65f * smoothed.value } }
val hasSignal = rssi != null
val p = ((smoothed.value + 100f) / 60f).coerceIn(0f, 1f) // -100 dBm..-40 dBm -> 0..1
val label = when {
!hasSignal -> "Searching… walk around"
p > 0.8f -> "Right here"
p > 0.6f -> "Very close"
p > 0.4f -> "Close"
p > 0.2f -> "Getting warmer"
else -> "Far"
}
val color = lerp(VigilRed, VigilGreen, p)
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
ecosystemDisplay(t.ecosystem),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(32.dp))
Box(
Modifier.size((120 + p * 160).dp).clip(CircleShape).background(
if (hasSignal) color.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.Sensors, null,
tint = if (hasSignal) color else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(64.dp)
)
}
Spacer(Modifier.height(32.dp))
Text(
label,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = if (hasSignal) color else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
if (hasSignal) "$rssi dBm" else "no signal yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(40.dp))
Button(onClick = { onRing(t) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Icon(Icons.Filled.NotificationsActive, null)
Spacer(Modifier.size(8.dp))
Text("Make it ring")
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Done") }
Spacer(Modifier.height(12.dp))
Text(
"If it won't ring, it may be a silent or modified tracker — use the signal above to home in on it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// ---- pure helpers ---------------------------------------------------------- // ---- 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 isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) { 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)
}
}