6 Commits

Author SHA1 Message Date
Kara Zajac a2f293a486 Release 0.1.2: Find it (hot/cold) + Make it ring
Build APK / build (push) Waiting to run
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) Waiting to run
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,
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
never transmits, probes, or interferes with any device.
in an on-device SQLite database and never leave the phone. **Detection is entirely
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
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)
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"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 3
versionName = "0.1.2"
}
// 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.Bundle
import android.provider.Settings as AndroidSettings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -17,7 +18,9 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.soulstone.vigil.data.TrackerRepository
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase
import org.soulstone.vigil.ring.TrackerRinger
import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.service.ScanService
import org.soulstone.vigil.ui.MainScreen
@@ -49,8 +52,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 +64,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,7 +91,11 @@ class MainActivity : ComponentActivity() {
onSetSensitivity = { settings.setSensitivity(it) },
onApprove = { 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() {
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 }
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")
@@ -1,6 +1,8 @@
package org.soulstone.vigil.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.soulstone.vigil.data.db.SightingEntity
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase
@@ -18,15 +20,28 @@ import org.soulstone.vigil.util.Geohash
*/
class TrackerRepository(private val db: VigilDatabase) {
// Serialises record() so the read-modify-write of a tracker row is atomic —
// concurrent observations were racing and losing count / first-seen updates.
private val mutex = Mutex()
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
suspend fun setApproved(id: String, approved: Boolean) =
// Under the same lock as record() so a concurrent observation can't clobber the
// user's choice back to its stale value.
suspend fun setApproved(id: String, approved: Boolean) = mutex.withLock {
db.trackerDao().setApproved(id, approved)
}
suspend fun clearBaseline(id: String) = mutex.withLock {
db.trackerDao().clearBaseline(id)
}
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
db.sightingDao().prune(System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS)
val cutoff = System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS
db.sightingDao().prune(cutoff)
db.trackerDao().pruneStale(cutoff)
}
/**
@@ -38,7 +53,7 @@ class TrackerRepository(private val db: VigilDatabase) {
lat: Double?,
lon: Double?,
sensitivity: Sensitivity
): RecordResult {
): RecordResult = mutex.withLock {
val now = obs.timestampMs
val geohash7 = if (lat != null && lon != null) Geohash.encode(lat, lon, 7) else null
val geohash6 = if (lat != null && lon != null) Geohash.encode(lat, lon, 6) else null
@@ -54,6 +69,15 @@ class TrackerRepository(private val db: VigilDatabase) {
)
val existing = db.trackerDao().get(obs.stableId)
val approved = existing?.approved ?: false
// Co-movement assessment first — it decides both display AND whether this
// tag is safe to baseline. Always computed (UI shows the evidence numbers).
val since = now - CoMovementEvaluator.WINDOW_MS
val recent = db.sightingDao().recentFor(obs.stableId, since)
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
val assessment = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t)
val coMoving = assessment.riskState != RiskState.OBSERVED
// --- baseline: learn tags that live where you live -------------------
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
@@ -61,7 +85,10 @@ class TrackerRepository(private val db: VigilDatabase) {
var baselineSafe = existing?.baselineSafe ?: false
if (geohash6 != null) {
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
if (isAnchor) {
// ONLY baseline-trust a tag that is NOT co-moving with you. A planted
// stalker tag co-moves (that's the whole detection), so it stays
// SUSPICIOUS/ALERTING and must never be auto-trusted into silence.
if (isAnchor && !coMoving) {
val day = now / BaselineManager.DAY_MS
if (day != lastAnchorDay) {
lastAnchorDay = day
@@ -70,19 +97,10 @@ class TrackerRepository(private val db: VigilDatabase) {
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
}
}
// Safety revoke: if a previously-trusted tag ever starts co-moving, drop trust.
if (baselineSafe && coMoving) baselineSafe = false
val approved = existing?.approved ?: false
// --- co-movement evaluation (skipped for trusted tags) ---------------
val riskState: RiskState
if (approved || baselineSafe) {
riskState = RiskState.OBSERVED
} else {
val since = now - CoMovementEvaluator.WINDOW_MS
val recent = db.sightingDao().recentFor(obs.stableId, since)
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
riskState = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t).riskState
}
val riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
@@ -100,10 +118,15 @@ class TrackerRepository(private val db: VigilDatabase) {
baselineSafe = baselineSafe,
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
lastAnchorDay = lastAnchorDay,
anchorDayCount = anchorDayCount
anchorDayCount = anchorDayCount,
lastRssi = obs.rssi,
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
distinctPlaces = assessment.distinctPlaces,
effectiveSightings = assessment.sightings,
lastMac = obs.mac
)
db.trackerDao().upsert(updated)
return RecordResult(updated, newlyAlerting)
RecordResult(updated, newlyAlerting)
}
companion object {
@@ -32,7 +32,14 @@ data class TrackerEntity(
val lastAlertMs: Long = 0,
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place
val lastAnchorDay: Long = -1,
val anchorDayCount: Int = 0
val anchorDayCount: Int = 0,
// display / grounding: latest + peak RSSI and the current co-movement evidence
val lastRssi: Int = 0,
val peakRssi: Int = -127,
val distinctPlaces: Int = 0,
val effectiveSightings: Int = 0,
// 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. */
@@ -74,6 +81,16 @@ interface TrackerDao {
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
suspend fun setApproved(id: String, approved: Boolean)
// User says a baseline-trusted tag is "not mine" — drop trust and restart the
// day clock so it doesn't immediately re-trust.
@Query("UPDATE trackers SET baselineSafe = 0, anchorDayCount = 0, lastAnchorDay = -1 WHERE stableId = :id")
suspend fun clearBaseline(id: String)
// Rotated identities pile up (MAC rotation mints a new row each rotation); drop
// stale, non-approved rows so the table and the UI list don't grow without bound.
@Query("DELETE FROM trackers WHERE lastSeen < :cutoff AND approved = 0")
suspend fun pruneStale(cutoff: Long)
}
@Dao
@@ -99,7 +116,7 @@ interface PlaceDao {
@Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 1,
version = 3,
exportSchema = false
)
abstract class VigilDatabase : RoomDatabase() {
@@ -18,23 +18,29 @@ import org.soulstone.vigil.data.db.PlaceEntity
object BaselineManager {
const val DAY_MS = 86_400_000L
const val ANCHOR_MIN_VISITS = 60
const val DWELL_BUCKET_MS = 10 * 60_000L // count at most one "visit" per 10-min dwell window
const val ANCHOR_MIN_VISITS = 18 // ~3h cumulative dwell before a cell counts as home/work
const val BASELINE_MIN_DAYS = 3
/** Record a location fix in its cell; returns whether that cell is now an anchor. */
/**
* Note presence in a cell; returns whether it's an anchor. Crucially a cell
* accrues at most ONE visit per [DWELL_BUCKET_MS], so the anchor signal tracks
* time actually spent there — not how many trackers or adverts were seen.
*
* The previous per-sighting count was a real safety flaw: a busy street or a
* single chatty tracker could mint a fake "home" in seconds, which would then
* baseline-trust (silence alerts for) any tracker seen there — including a real
* stalking device.
*/
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
val existing = placeDao.get(geohash6)
val visits = (existing?.visitCount ?: 0) + 1
val existing = placeDao.get(geohash6) ?: run {
placeDao.upsert(PlaceEntity(geohash6 = geohash6, visitCount = 1, lastSeen = now, anchor = false))
return false
}
if (now - existing.lastSeen < DWELL_BUCKET_MS) return existing.anchor
val visits = existing.visitCount + 1
val anchor = visits >= ANCHOR_MIN_VISITS
placeDao.upsert(
PlaceEntity(
geohash6 = geohash6,
label = existing?.label ?: "",
visitCount = visits,
lastSeen = now,
anchor = anchor
)
)
placeDao.upsert(existing.copy(visitCount = visits, lastSeen = now, anchor = anchor))
return anchor
}
}
@@ -59,11 +59,14 @@ object CoMovementEvaluator {
val sorted = sightings.sortedBy { it.timestamp }
// Debounce: one effective sighting per DEDUP_MS so a chatty tag at 2s
// intervals doesn't trivially clear the count.
// intervals doesn't trivially clear the count. lastCounted is NULLABLE and
// always counts the first sighting — the old `Long.MIN_VALUE` seed overflowed
// (timestamp - Long.MIN_VALUE wraps negative), which pinned `effective` at 0
// and made the detector unable to EVER alert. (Caught by unit test.)
var effective = 0
var lastCounted = Long.MIN_VALUE
var lastCounted: Long? = null
for (s in sorted) {
if (s.timestamp - lastCounted >= DEDUP_MS) {
if (lastCounted == null || s.timestamp - lastCounted >= DEDUP_MS) {
effective++
lastCounted = s.timestamp
}
@@ -137,8 +137,11 @@ class PresenceEngine {
score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20
score += (if (coherent) 1.0 else 0.4) * 20
val s = score.roundToInt()
// Coherence GATES the top tier: a crowd of many radios at many distances is
// incoherent (high RSSI variance), so it can reach PROBABLE at most, never
// CONFIRMED — this stops packed-transit false clone alarms.
val tier = when {
s >= 85 -> Tier.CONFIRMED
s >= 85 && coherent -> Tier.CONFIRMED
s >= 70 -> Tier.PROBABLE
s >= 40 -> Tier.WATCHING
else -> Tier.CLEAR
@@ -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()
private val filters: List<ScanFilter> = buildList {
// Apple Find My — match presence of Apple manufacturer data.
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build())
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
// Apple Find My — match ONLY the offline-finding message type (0x12), not
// every Apple device (iPhones/Watches/AirPods all advertise company 0x004C).
// Data+mask apply from the first manufacturer-data byte, which is the type.
add(
ScanFilter.Builder().setManufacturerData(
TrackerSignatures.APPLE_COMPANY_ID,
byteArrayOf(TrackerSignatures.APPLE_TYPE_FINDMY.toByte()),
byteArrayOf(0xFF.toByte())
).build()
)
// FMDN / Samsung / Tile / DULT — match by SERVICE DATA presence, not the
// service-UUID list. These carry their payload in service data and may not
// advertise the plain UUID-list entry, so setServiceUuid can miss them
// entirely (AirGuard uses service-data presence filters for exactly this).
for (uuid in TrackerSignatures.trackerServiceUuids) {
add(ScanFilter.Builder().setServiceUuid(uuid).build())
add(ScanFilter.Builder().setServiceData(uuid, ByteArray(0)).build())
}
}
@@ -26,15 +26,16 @@ object TrackerParser {
// --- Apple Find My (manufacturer data, company 0x004C) ---
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
if (d.isNotEmpty() && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
// Android strips the 2-byte company id, so d = [type(0x12),
// length(0x19), status, key(22), keyTopBits, hint]. The maintained
// bit lives in the status byte (index 2): set => near owner,
// cleared/absent => treat as separated.
val status = if (d.size > 2) d[2].toInt() and 0xFF else 0
// Require the FULL offline-finding frame. After Android strips the company
// id, d = [type, len, status, key(22), keyTopBits, hint] (~27 bytes). Short
// "nearby" frames carry no key — ignore them rather than fabricate a shared
// identity from the type/status bytes, which merged many distinct devices
// into one phantom "tracker" and misclassified it as separated.
if (d.size >= 25 && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
val status = d[2].toInt() and 0xFF // maintained bit set => near owner
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
val keyBytes = if (d.size >= 25) d.copyOfRange(3, 25) else d
val keyBytes = d.copyOfRange(3, 25)
return TrackerObservation(
stableId = "apple:" + toHex(keyBytes),
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
@@ -12,6 +12,7 @@ import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
@@ -49,6 +50,10 @@ class ScanService : LifecycleService() {
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L
private const val MIN_RECORD_INTERVAL_MS = 15_000L // per-device DB throttle
private const val ALERT_CHANNEL_ID = "vigil_alerts"
private const val ALERT_NOTIF_ID = 0x5162
private const val CLONE_NOTIF_ID = 0x5163
const val ACTION_START = "org.soulstone.vigil.action.START"
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
@@ -56,6 +61,16 @@ class ScanService : LifecycleService() {
private val _running = MutableStateFlow(false)
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) {
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -75,7 +90,8 @@ class ScanService : LifecycleService() {
private lateinit var location: LocationProvider
private lateinit var scanner: BleTrackerScanner
private val presence = PresenceEngine()
private var lastCloneAlertMs = 0L
private val lastRecordedAt = ConcurrentHashMap<String, Long>()
@Volatile private var lastCloneAlertMs = 0L
private var pruneJob: Job? = null
override fun onCreate() {
@@ -109,8 +125,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)
}
}
}
@@ -118,6 +136,9 @@ class ScanService : LifecycleService() {
private fun onObservation(obs: TrackerObservation) {
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
// streaming state stays ordered. Only a CONFIRMED verdict raises a user
// 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 {
val result = runCatching {
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
@@ -141,10 +171,13 @@ class ScanService : LifecycleService() {
}
private fun end() {
if (!_running.value) return
// No early-return guard: all stops below are idempotent, and begin() calls
// end() on a failed scanner start (when _running was never set) to make sure
// the location updates and foreground notification are released, not leaked.
_running.value = false
scanner.stop()
location.stop()
lastRecordedAt.clear()
pruneJob?.cancel(); pruneJob = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -177,9 +210,9 @@ class ScanService : LifecycleService() {
val n = buildNotification(
title = "Tracker following you",
text = "${tracker.label} has been moving with you",
high = true
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, n)
getSystemService(NotificationManager::class.java)?.notify(ALERT_NOTIF_ID, n)
vibrate()
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
}
@@ -188,9 +221,9 @@ class ScanService : LifecycleService() {
val n = buildNotification(
title = "Possible hidden tracker",
text = "A rotating-ID tracker appears to be moving with you",
high = true
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID + 1, n)
getSystemService(NotificationManager::class.java)?.notify(CLONE_NOTIF_ID, n)
vibrate()
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
}
@@ -208,7 +241,13 @@ class ScanService : LifecycleService() {
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
private fun buildNotification(title: String, text: String, high: Boolean): Notification {
private fun buildNotification(
title: String,
text: String,
high: Boolean,
channelId: String = CHANNEL_ID,
ongoing: Boolean = true
): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
@@ -216,31 +255,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)
}
)
}
}
}
@@ -1,6 +1,14 @@
package org.soulstone.vigil.ui
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.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -36,6 +44,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -72,11 +81,17 @@ fun MainScreen(
permissionMessage: String?,
onStartStop: () -> 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 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 alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
@@ -159,14 +174,14 @@ fun MainScreen(
}
} else {
items(active, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
}
}
if (trusted.isNotEmpty()) {
item { SectionHeader("Trusted (${trusted.size})") }
items(trusted, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
}
}
@@ -188,9 +203,23 @@ 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 },
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
@@ -283,7 +312,12 @@ private fun SectionHeader(text: String) {
}
@Composable
private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (String, Boolean) -> Unit) {
private fun TrackerCard(
t: TrackerEntity,
onClick: () -> Unit,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
) {
val status = statusOf(t)
val accent = statusColor(status)
Card(
@@ -302,7 +336,7 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
Column(Modifier.weight(1f)) {
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
Text(
"${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}",
"${statusLabel(status)} · ${t.distinctPlaces} places · ${t.lastRssi} dBm · ${relative(t.lastSeen)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -310,10 +344,8 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
when (status) {
TrackerStatus.SAFE_APPROVED ->
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
TrackerStatus.SAFE_BASELINE -> Icon(
Icons.Filled.Verified, null,
tint = VigilGreen, modifier = Modifier.size(20.dp)
)
TrackerStatus.SAFE_BASELINE ->
TextButton(onClick = { onDistrust(t.stableId) }) { Text("Not mine") }
else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's mine") }
}
}
@@ -321,7 +353,13 @@ 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,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
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)
Spacer(Modifier.height(16.dp))
DetailRow("Sightings", t.sightingCount.toString())
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
DetailRow("Distinct places", t.distinctPlaces.toString())
DetailRow("Co-movement sightings", t.effectiveSightings.toString())
DetailRow("Adverts logged", t.sightingCount.toString())
DetailRow("First seen", relative(t.firstSeen))
DetailRow("Last seen", relative(t.lastSeen))
if (t.anchorDayCount > 0) {
DetailRow("Days seen at your places", "${t.anchorDayCount} (trusted at 3)")
DetailRow("Days at your places", "${t.anchorDayCount} / 3 to trust")
}
DetailRow("Identity", t.stableId.take(22) + "")
@@ -348,15 +389,33 @@ 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")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Sensors, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(6.dp))
Text("Find it")
}
} else if (status != TrackerStatus.SAFE_BASELINE) {
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
Text("This is mine — stop alerting")
FilledTonalButton(onClick = { onRing(t) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.NotificationsActive, null, modifier = Modifier.size(18.dp))
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))
}
}
@@ -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 ----------------------------------------------------------
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) {
@@ -0,0 +1,77 @@
package org.soulstone.vigil.detect
import org.junit.Assert.assertEquals
import org.junit.Test
import org.soulstone.vigil.data.db.SightingEntity
import org.soulstone.vigil.model.RiskState
import org.soulstone.vigil.model.Sensitivity
import org.soulstone.vigil.model.TrackerEcosystem
/** Locks the co-movement alert logic: dedup, the RSSI proximity gate, and the
* distinct-places / separated-state thresholds. MEDIUM = 3 sightings / 3 places /
* 45 min / -85 dBm. */
class CoMovementEvaluatorTest {
private val t = CoMovementEvaluator.thresholdsFor(Sensitivity.MEDIUM)
private fun s(tsMin: Long, rssi: Int, cell: String?, separated: Boolean) =
SightingEntity(trackerId = "x", timestamp = tsMin * 60_000L, rssi = rssi, separated = separated, geohash7 = cell)
@Test fun emptyIsObserved() {
val a = CoMovementEvaluator.evaluate(emptyList(), TrackerEcosystem.APPLE_FIND_MY, t)
assertEquals(RiskState.OBSERVED, a.riskState)
assertEquals(0, a.distinctPlaces)
}
@Test fun separatedCloseAcrossPlacesAlerts() {
val list = listOf(
s(0, -70, "u000001", true),
s(30, -68, "u000002", true),
s(60, -72, "u000003", true)
)
val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t)
assertEquals(RiskState.ALERTING, a.riskState)
assertEquals(3, a.distinctPlaces)
assertEquals(3, a.sightings)
}
@Test fun farSignalIsGatedOut() {
val list = listOf(
s(0, -95, "u000001", true),
s(30, -96, "u000002", true),
s(60, -97, "u000003", true)
)
// Seen across 3 places over an hour, but never close -> the RSSI gate holds it back.
assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState)
}
@Test fun tooFewPlacesIsSuspiciousNotAlerting() {
val list = listOf(
s(0, -70, "u000001", true),
s(30, -70, "u000001", true),
s(60, -70, "u000002", true)
)
val a = CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t)
assertEquals(RiskState.SUSPICIOUS, a.riskState)
assertEquals(2, a.distinctPlaces)
}
@Test fun nearOwnerNeverEscalates() {
val list = listOf(
s(0, -70, "u000001", false),
s(30, -70, "u000002", false),
s(60, -70, "u000003", false)
)
assertEquals(RiskState.OBSERVED, CoMovementEvaluator.evaluate(list, TrackerEcosystem.APPLE_FIND_MY, t).riskState)
}
@Test fun tileHasNoSeparatedFlagButStillCounts() {
val list = listOf(
s(0, -70, "u000001", false),
s(30, -70, "u000002", false),
s(60, -70, "u000003", false)
)
// Tile emits no separated flag, so any Tile is a live candidate.
assertEquals(RiskState.ALERTING, CoMovementEvaluator.evaluate(list, TrackerEcosystem.TILE, t).riskState)
}
}