Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ab2f21a4 | |||
| 333dd291cb | |||
| bf15ead1ef | |||
| 6c460cbff7 | |||
| a2f293a486 | |||
| 4af3a2380a | |||
| 0efc205220 | |||
| f952a8e7ca | |||
| 98edf4345a | |||
| de58944ce7 |
@@ -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
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.soulstone.vigil"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 7
|
||||
versionName = "0.1.6"
|
||||
}
|
||||
|
||||
// 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,12 @@ 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) },
|
||||
onClearAll = { lifecycleScope.launch { repo.clearAll() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -94,11 +104,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,35 @@ 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)
|
||||
}
|
||||
|
||||
/** Wipe the whole tracker list + sighting history (they re-populate as devices
|
||||
* are re-detected). Keeps learned baseline places. */
|
||||
suspend fun clearAll() = mutex.withLock {
|
||||
db.trackerDao().clearAll()
|
||||
db.sightingDao().clearAll()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +60,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 +76,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 +92,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 +104,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 +125,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,19 @@ 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)
|
||||
|
||||
@Query("DELETE FROM trackers")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
@@ -86,6 +106,9 @@ interface SightingDao {
|
||||
|
||||
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
||||
suspend fun prune(cutoff: Long)
|
||||
|
||||
@Query("DELETE FROM sightings")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
@@ -99,7 +122,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,191 @@
|
||||
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)
|
||||
"Ring command sent. A tag only chirps when it's separated from its owner — your own tag that's with you won't."
|
||||
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,15 @@
|
||||
package org.soulstone.vigil.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.BackHandler
|
||||
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
|
||||
@@ -19,6 +28,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GppMaybe
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Radar
|
||||
@@ -33,9 +43,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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 +84,18 @@ fun MainScreen(
|
||||
permissionMessage: String?,
|
||||
onStartStop: () -> Unit,
|
||||
onSetSensitivity: (Sensitivity) -> Unit,
|
||||
onApprove: (String, Boolean) -> Unit
|
||||
onApprove: (String, Boolean) -> Unit,
|
||||
onDistrust: (String) -> Unit,
|
||||
onRing: (TrackerEntity) -> Unit,
|
||||
onClearAll: () -> 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 }
|
||||
@@ -93,6 +112,15 @@ fun MainScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (trackers.isNotEmpty()) {
|
||||
IconButton(onClick = onClearAll) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteSweep,
|
||||
contentDescription = "Clear all trackers",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Lock, null,
|
||||
@@ -159,14 +187,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 +216,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 +325,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 +349,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 +357,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 +366,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 +384,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 +402,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 +441,84 @@ 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) {
|
||||
// Intercept system back so it closes the finder instead of exiting the app.
|
||||
BackHandler(onBack = onClose)
|
||||
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,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Mocha radial ground: a subtle lift toward the top-centre, falling to crust. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="54"
|
||||
android:centerY="42"
|
||||
android:gradientRadius="82"
|
||||
android:startColor="#2A2A40"
|
||||
android:endColor="#11111B" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -1,13 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- VIGIL "Radar Eye" — a watchful eye whose iris is a radar target. Content sits
|
||||
within the adaptive-icon safe zone (centre ~66dp of the 108dp canvas). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- eye outline -->
|
||||
<path
|
||||
android:fillColor="#1FAA59"
|
||||
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
|
||||
android:pathData="M26,54 Q54,30 82,54 Q54,78 26,54 Z"
|
||||
android:strokeColor="#89B4FA"
|
||||
android:strokeWidth="4.5"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- radar iris — outer ring -->
|
||||
<path
|
||||
android:fillColor="#F4F6FA"
|
||||
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
|
||||
android:pathData="M54,54 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0"
|
||||
android:strokeColor="#89B4FA"
|
||||
android:strokeWidth="3.2"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- radar iris — inner ring -->
|
||||
<path
|
||||
android:pathData="M54,54 m-6.5,0 a6.5,6.5 0 1,0 13,0 a6.5,6.5 0 1,0 -13,0"
|
||||
android:strokeColor="#B4BEFE"
|
||||
android:strokeWidth="2.8"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- centre contact (the detected device) -->
|
||||
<path
|
||||
android:pathData="M54,54 m-3.2,0 a3.2,3.2 0 1,0 6.4,0 a3.2,3.2 0 1,0 -6.4,0"
|
||||
android:fillColor="#A6E3A1" />
|
||||
</vector>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/bg_dark" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/bg_dark" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user