Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fbf97b93e | |||
| c716ab6761 | |||
| 4b42b4431c | |||
| 092552939d | |||
| b8da7e87ff | |||
| 24a2dc83e5 | |||
| 1c0a6f216d | |||
| a71a83891a | |||
| b72faea8ce | |||
| ee13e10173 | |||
| 51cd474573 | |||
| 6e5b565e5e | |||
| 52797cf52f | |||
| f4635967c7 | |||
| 41ab2f21a4 | |||
| 333dd291cb | |||
| bf15ead1ef | |||
| 6c460cbff7 | |||
| a2f293a486 | |||
| 4af3a2380a | |||
| 0efc205220 | |||
| f952a8e7ca | |||
| 98edf4345a | |||
| de58944ce7 |
@@ -1,7 +1,13 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="docs/logo.png" alt="VIGIL" width="96" height="96" />
|
||||||
|
|
||||||
# VIGIL
|
# VIGIL
|
||||||
|
|
||||||
**What's been following you?**
|
**What's been following you?**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
A native Android (Kotlin) app that watches for personal item-trackers — Apple
|
A native Android (Kotlin) app that watches for personal item-trackers — Apple
|
||||||
AirTags, Tile, Samsung Galaxy SmartTags, and Google Find My Device / DULT tags —
|
AirTags, Tile, Samsung Galaxy SmartTags, and Google Find My Device / DULT tags —
|
||||||
that are **travelling with you over time**. It is the temporal counterpart to
|
that are **travelling with you over time**. It is the temporal counterpart to
|
||||||
@@ -22,12 +28,27 @@ that idea.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Privacy first — like OVERWATCH
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/main.png" width="230" alt="VIGIL watch list — whether anything has been following you" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/finder-searching.png" width="230" alt="Hot/cold finder searching for a tracker" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/finder-close.png" width="230" alt="Finder homing in on a device, with Make it ring" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><sub>The watch list · the hot/cold finder searching · homing in on a tracker, with “Make it ring”</sub></p>
|
||||||
|
|
||||||
|
## Privacy first — fully offline
|
||||||
|
|
||||||
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
|
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
|
||||||
no telemetry. Every tracker, every sighting, and the entire learned baseline live
|
no telemetry. Every tracker, every sighting, and the entire learned baseline live
|
||||||
in an on-device SQLite database and never leave the phone. It listens only — it
|
in an on-device SQLite database and never leave the phone. **Detection is entirely
|
||||||
never transmits, probes, or interferes with any device.
|
passive — it listens only.** The one exception is the user-initiated **"Make it
|
||||||
|
ring"** action, which connects to a tracker you already suspect and asks it to play
|
||||||
|
a sound (the DULT-standard way for a victim to locate a hidden tag); nothing is
|
||||||
|
transmitted unless you tap it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,6 +92,17 @@ Two trust signals suppress false alarms:
|
|||||||
auto-marked **Known (home)**. So the household tags that are always around you
|
auto-marked **Known (home)**. So the household tags that are always around you
|
||||||
fall silent on their own, entirely on-device.
|
fall silent on their own, entirely on-device.
|
||||||
|
|
||||||
|
## Finding a tracker
|
||||||
|
|
||||||
|
Tap any tracker for two ways to physically locate it:
|
||||||
|
- **Make it ring** — connects over GATT and plays the tracker's own sound. Works for
|
||||||
|
AirTags (native `0xAF` sound), Google Find My Device, and DULT tags (Chipolo,
|
||||||
|
Pebblebee, eufy, Motorola). Samsung SmartTag and Tile expose no non-owner ring, so
|
||||||
|
VIGIL points you to the SmartThings / Tile app instead.
|
||||||
|
- **Hot/cold finder** — a passive proximity meter that turns live signal strength into
|
||||||
|
a warmer/colder readout. It still works on **silent or modified tags that refuse to
|
||||||
|
ring**, which is exactly when you need it most.
|
||||||
|
|
||||||
## The hard part — catching clones (problem #1)
|
## The hard part — catching clones (problem #1)
|
||||||
|
|
||||||
Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device
|
Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "org.soulstone.vigil"
|
applicationId = "org.soulstone.vigil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 13
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
||||||
|
|||||||
@@ -67,5 +67,16 @@
|
|||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="connectedDevice|location" />
|
android:foregroundServiceType="connectedDevice|location" />
|
||||||
|
|
||||||
|
<!-- Shares exported evidence files (report + GPX) with other apps. No network. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -7,20 +7,30 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings as AndroidSettings
|
import android.provider.Settings as AndroidSettings
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import org.soulstone.vigil.data.TrackerRepository
|
import org.soulstone.vigil.data.TrackerRepository
|
||||||
|
import org.soulstone.vigil.data.db.TrackerEntity
|
||||||
import org.soulstone.vigil.data.db.VigilDatabase
|
import org.soulstone.vigil.data.db.VigilDatabase
|
||||||
|
import org.soulstone.vigil.ring.TrackerRinger
|
||||||
import org.soulstone.vigil.data.settings.Settings
|
import org.soulstone.vigil.data.settings.Settings
|
||||||
import org.soulstone.vigil.service.ScanService
|
import org.soulstone.vigil.service.ScanService
|
||||||
|
import org.soulstone.vigil.ui.HistoryScreen
|
||||||
import org.soulstone.vigil.ui.MainScreen
|
import org.soulstone.vigil.ui.MainScreen
|
||||||
|
import org.soulstone.vigil.ui.OnboardingScreen
|
||||||
|
import org.soulstone.vigil.ui.SafetyScreen
|
||||||
import org.soulstone.vigil.ui.theme.VigilTheme
|
import org.soulstone.vigil.ui.theme.VigilTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -49,8 +59,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
) { result ->
|
) { _ ->
|
||||||
val granted = result.all { it.value }
|
// Scanning needs BLE + location; POST_NOTIFICATIONS is optional and must not
|
||||||
|
// block protection if the user declines it.
|
||||||
|
val granted = hasEssentialPermissions()
|
||||||
permissionsGranted.value = granted
|
permissionsGranted.value = granted
|
||||||
if (granted) ScanService.start(this)
|
if (granted) ScanService.start(this)
|
||||||
}
|
}
|
||||||
@@ -59,46 +71,111 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
settings = Settings.get(this)
|
settings = Settings.get(this)
|
||||||
repo = TrackerRepository(VigilDatabase.get(this))
|
repo = TrackerRepository(VigilDatabase.get(this))
|
||||||
permissionsGranted.value = hasAllPermissions()
|
permissionsGranted.value = hasEssentialPermissions()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
VigilTheme {
|
VigilTheme {
|
||||||
val running by ScanService.running.collectAsState()
|
val onboarded by settings.onboarded.collectAsState()
|
||||||
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
|
var showSafety by remember { mutableStateOf(false) }
|
||||||
val sensitivity by settings.sensitivity.collectAsState()
|
var showHistory by remember { mutableStateOf(false) }
|
||||||
val granted by permissionsGranted
|
when {
|
||||||
|
!onboarded -> OnboardingScreen(onFinish = {
|
||||||
MainScreen(
|
settings.setOnboarded(true)
|
||||||
running = running,
|
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
|
||||||
trackers = trackers,
|
})
|
||||||
sensitivity = sensitivity,
|
showSafety -> SafetyScreen(onBack = { showSafety = false })
|
||||||
permissionMessage = if (granted) null
|
showHistory -> {
|
||||||
else "Grant Bluetooth + location to start watching",
|
val alerts by repo.observeAlerts().collectAsState(initial = emptyList())
|
||||||
onStartStop = {
|
HistoryScreen(
|
||||||
if (running) {
|
alerts = alerts,
|
||||||
ScanService.stop(this)
|
onExport = { exportEvidence() },
|
||||||
} else if (granted) {
|
onClear = { lifecycleScope.launch { repo.clearAlerts() } },
|
||||||
ScanService.start(this)
|
onBack = { showHistory = false }
|
||||||
} else {
|
)
|
||||||
permissionLauncher.launch(requiredPermissions)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSetSensitivity = { settings.setSensitivity(it) },
|
|
||||||
onApprove = { id, approved ->
|
|
||||||
lifecycleScope.launch { repo.setApproved(id, approved) }
|
|
||||||
}
|
}
|
||||||
)
|
else -> {
|
||||||
|
val running by ScanService.running.collectAsState()
|
||||||
|
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
|
||||||
|
val sensitivity by settings.sensitivity.collectAsState()
|
||||||
|
val granted by permissionsGranted
|
||||||
|
|
||||||
|
MainScreen(
|
||||||
|
running = running,
|
||||||
|
trackers = trackers,
|
||||||
|
sensitivity = sensitivity,
|
||||||
|
permissionMessage = if (granted) null
|
||||||
|
else "Grant Bluetooth + location to start watching",
|
||||||
|
onStartStop = {
|
||||||
|
if (running) {
|
||||||
|
ScanService.stop(this)
|
||||||
|
} else if (granted) {
|
||||||
|
ScanService.start(this)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(requiredPermissions)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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() } },
|
||||||
|
onOpenSafety = { showSafety = true },
|
||||||
|
onOpenHistory = { showHistory = true },
|
||||||
|
loadTrail = { id -> repo.trailFor(id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
permissionsGranted.value = hasAllPermissions()
|
permissionsGranted.value = hasEssentialPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasAllPermissions(): Boolean = requiredPermissions.all {
|
private fun hasEssentialPermissions(): Boolean = requiredPermissions
|
||||||
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
.filter { it != Manifest.permission.POST_NOTIFICATIONS }
|
||||||
|
.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
|
private fun ringTracker(tracker: TrackerEntity) {
|
||||||
|
Toast.makeText(this, "Trying to ring ${tracker.label}…", Toast.LENGTH_SHORT).show()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val msg = TrackerRinger.ring(this@MainActivity, tracker.lastMac, tracker.ecosystem)
|
||||||
|
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportEvidence() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val report = repo.buildTextReport()
|
||||||
|
if (report == null) {
|
||||||
|
Toast.makeText(this@MainActivity, "No alerts to export yet.", Toast.LENGTH_SHORT).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val dir = File(cacheDir, "exports").apply { mkdirs() }
|
||||||
|
val txt = File(dir, "vigil-report.txt").apply { writeText(report) }
|
||||||
|
val gpx = File(dir, "vigil-track.gpx").apply { writeText(repo.buildGpx()) }
|
||||||
|
val auth = "$packageName.fileprovider"
|
||||||
|
val uris = arrayListOf(
|
||||||
|
FileProvider.getUriForFile(this@MainActivity, auth, txt),
|
||||||
|
FileProvider.getUriForFile(this@MainActivity, auth, gpx)
|
||||||
|
)
|
||||||
|
val share = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||||
|
type = "*/*"
|
||||||
|
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(share, "Export VIGIL evidence"))
|
||||||
|
}.onFailure {
|
||||||
|
Toast.makeText(this@MainActivity, "Export failed: ${it.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.soulstone.vigil.data
|
package org.soulstone.vigil.data
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.soulstone.vigil.data.db.AlertEntity
|
||||||
import org.soulstone.vigil.data.db.SightingEntity
|
import org.soulstone.vigil.data.db.SightingEntity
|
||||||
import org.soulstone.vigil.data.db.TrackerEntity
|
import org.soulstone.vigil.data.db.TrackerEntity
|
||||||
import org.soulstone.vigil.data.db.VigilDatabase
|
import org.soulstone.vigil.data.db.VigilDatabase
|
||||||
@@ -9,8 +12,14 @@ import org.soulstone.vigil.detect.CoMovementEvaluator
|
|||||||
import org.soulstone.vigil.model.RiskState
|
import org.soulstone.vigil.model.RiskState
|
||||||
import org.soulstone.vigil.model.SeparatedState
|
import org.soulstone.vigil.model.SeparatedState
|
||||||
import org.soulstone.vigil.model.Sensitivity
|
import org.soulstone.vigil.model.Sensitivity
|
||||||
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
import org.soulstone.vigil.model.TrackerObservation
|
import org.soulstone.vigil.model.TrackerObservation
|
||||||
|
import org.soulstone.vigil.model.TrailPoint
|
||||||
import org.soulstone.vigil.util.Geohash
|
import org.soulstone.vigil.util.Geohash
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The temporal core. Persists sightings, maintains the learned baseline, and runs
|
* The temporal core. Persists sightings, maintains the learned baseline, and runs
|
||||||
@@ -18,15 +27,35 @@ import org.soulstone.vigil.util.Geohash
|
|||||||
*/
|
*/
|
||||||
class TrackerRepository(private val db: VigilDatabase) {
|
class TrackerRepository(private val db: VigilDatabase) {
|
||||||
|
|
||||||
|
// Serialises record() so the read-modify-write of a tracker row is atomic —
|
||||||
|
// concurrent observations were racing and losing count / first-seen updates.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
|
data class RecordResult(val tracker: TrackerEntity, val newlyAlerting: Boolean)
|
||||||
|
|
||||||
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
|
fun observeTrackers(): Flow<List<TrackerEntity>> = db.trackerDao().observeAll()
|
||||||
|
|
||||||
suspend fun setApproved(id: String, approved: Boolean) =
|
// Under the same lock as record() so a concurrent observation can't clobber the
|
||||||
|
// user's choice back to its stale value.
|
||||||
|
suspend fun setApproved(id: String, approved: Boolean) = mutex.withLock {
|
||||||
db.trackerDao().setApproved(id, approved)
|
db.trackerDao().setApproved(id, approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearBaseline(id: String) = mutex.withLock {
|
||||||
|
db.trackerDao().clearBaseline(id)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
|
suspend fun prune(retentionDays: Int = RETENTION_DAYS) {
|
||||||
db.sightingDao().prune(System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS)
|
val cutoff = System.currentTimeMillis() - retentionDays * BaselineManager.DAY_MS
|
||||||
|
db.sightingDao().prune(cutoff)
|
||||||
|
db.trackerDao().pruneStale(cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 +67,7 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
lat: Double?,
|
lat: Double?,
|
||||||
lon: Double?,
|
lon: Double?,
|
||||||
sensitivity: Sensitivity
|
sensitivity: Sensitivity
|
||||||
): RecordResult {
|
): RecordResult = mutex.withLock {
|
||||||
val now = obs.timestampMs
|
val now = obs.timestampMs
|
||||||
val geohash7 = if (lat != null && lon != null) Geohash.encode(lat, lon, 7) else null
|
val geohash7 = if (lat != null && lon != null) Geohash.encode(lat, lon, 7) else null
|
||||||
val geohash6 = if (lat != null && lon != null) Geohash.encode(lat, lon, 6) else null
|
val geohash6 = if (lat != null && lon != null) Geohash.encode(lat, lon, 6) else null
|
||||||
@@ -54,6 +83,15 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val existing = db.trackerDao().get(obs.stableId)
|
val existing = db.trackerDao().get(obs.stableId)
|
||||||
|
val approved = existing?.approved ?: false
|
||||||
|
|
||||||
|
// Co-movement assessment first — it decides both display AND whether this
|
||||||
|
// tag is safe to baseline. Always computed (UI shows the evidence numbers).
|
||||||
|
val since = now - CoMovementEvaluator.WINDOW_MS
|
||||||
|
val recent = db.sightingDao().recentFor(obs.stableId, since)
|
||||||
|
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
|
||||||
|
val assessment = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t)
|
||||||
|
val coMoving = assessment.riskState != RiskState.OBSERVED
|
||||||
|
|
||||||
// --- baseline: learn tags that live where you live -------------------
|
// --- baseline: learn tags that live where you live -------------------
|
||||||
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
|
var lastAnchorDay = existing?.lastAnchorDay ?: -1L
|
||||||
@@ -61,7 +99,10 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
var baselineSafe = existing?.baselineSafe ?: false
|
var baselineSafe = existing?.baselineSafe ?: false
|
||||||
if (geohash6 != null) {
|
if (geohash6 != null) {
|
||||||
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
|
val isAnchor = BaselineManager.noteLocation(db.placeDao(), geohash6, now)
|
||||||
if (isAnchor) {
|
// ONLY baseline-trust a tag that is NOT co-moving with you. A planted
|
||||||
|
// stalker tag co-moves (that's the whole detection), so it stays
|
||||||
|
// SUSPICIOUS/ALERTING and must never be auto-trusted into silence.
|
||||||
|
if (isAnchor && !coMoving) {
|
||||||
val day = now / BaselineManager.DAY_MS
|
val day = now / BaselineManager.DAY_MS
|
||||||
if (day != lastAnchorDay) {
|
if (day != lastAnchorDay) {
|
||||||
lastAnchorDay = day
|
lastAnchorDay = day
|
||||||
@@ -70,19 +111,10 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
|
if (anchorDayCount >= BaselineManager.BASELINE_MIN_DAYS) baselineSafe = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Safety revoke: if a previously-trusted tag ever starts co-moving, drop trust.
|
||||||
|
if (baselineSafe && coMoving) baselineSafe = false
|
||||||
|
|
||||||
val approved = existing?.approved ?: false
|
val riskState = if (approved || baselineSafe) RiskState.OBSERVED else assessment.riskState
|
||||||
|
|
||||||
// --- co-movement evaluation (skipped for trusted tags) ---------------
|
|
||||||
val riskState: RiskState
|
|
||||||
if (approved || baselineSafe) {
|
|
||||||
riskState = RiskState.OBSERVED
|
|
||||||
} else {
|
|
||||||
val since = now - CoMovementEvaluator.WINDOW_MS
|
|
||||||
val recent = db.sightingDao().recentFor(obs.stableId, since)
|
|
||||||
val t = CoMovementEvaluator.thresholdsFor(sensitivity)
|
|
||||||
riskState = CoMovementEvaluator.evaluate(recent, obs.ecosystem, t).riskState
|
|
||||||
}
|
|
||||||
|
|
||||||
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
|
val wasAlerting = existing?.riskState == RiskState.ALERTING.name
|
||||||
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
|
val cooldownOk = now - (existing?.lastAlertMs ?: 0) > CoMovementEvaluator.ALERT_COOLDOWN_MS
|
||||||
@@ -100,13 +132,106 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
baselineSafe = baselineSafe,
|
baselineSafe = baselineSafe,
|
||||||
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
|
lastAlertMs = if (newlyAlerting) now else (existing?.lastAlertMs ?: 0),
|
||||||
lastAnchorDay = lastAnchorDay,
|
lastAnchorDay = lastAnchorDay,
|
||||||
anchorDayCount = anchorDayCount
|
anchorDayCount = anchorDayCount,
|
||||||
|
lastRssi = obs.rssi,
|
||||||
|
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
|
||||||
|
distinctPlaces = assessment.distinctPlaces,
|
||||||
|
effectiveSightings = assessment.sightings,
|
||||||
|
lastMac = obs.mac
|
||||||
)
|
)
|
||||||
db.trackerDao().upsert(updated)
|
db.trackerDao().upsert(updated)
|
||||||
return RecordResult(updated, newlyAlerting)
|
if (newlyAlerting) {
|
||||||
|
db.alertDao().insert(
|
||||||
|
AlertEntity(
|
||||||
|
trackerId = obs.stableId,
|
||||||
|
ecosystem = obs.ecosystem.name,
|
||||||
|
label = obs.label,
|
||||||
|
timestamp = now,
|
||||||
|
distinctPlaces = assessment.distinctPlaces,
|
||||||
|
peakRssi = updated.peakRssi,
|
||||||
|
lat = lat, lon = lon
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RecordResult(updated, newlyAlerting)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeAlerts(): Flow<List<AlertEntity>> = db.alertDao().observeAll()
|
||||||
|
|
||||||
|
suspend fun clearAlerts() = db.alertDao().clear()
|
||||||
|
|
||||||
|
/** Geotagged sightings of one tracker, in time order, for the in-app trail. */
|
||||||
|
suspend fun trailFor(id: String): List<TrailPoint> =
|
||||||
|
db.sightingDao().allFor(id).mapNotNull { s ->
|
||||||
|
val la = s.lat; val lo = s.lon
|
||||||
|
if (la != null && lo != null) TrailPoint(la, lo) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable evidence report; null if there are no alerts yet. */
|
||||||
|
suspend fun buildTextReport(): String? {
|
||||||
|
val alerts = db.alertDao().all()
|
||||||
|
if (alerts.isEmpty()) return null
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.appendLine("VIGIL — tracker evidence report")
|
||||||
|
sb.appendLine("Generated: ${fmt.format(Date())}")
|
||||||
|
sb.appendLine("Times are this phone's local time; locations are approximate (phone GPS).")
|
||||||
|
sb.appendLine("=".repeat(52))
|
||||||
|
for ((trackerId, group) in alerts.groupBy { it.trackerId }) {
|
||||||
|
val head = group.first()
|
||||||
|
sb.appendLine()
|
||||||
|
sb.appendLine("${ecoLabel(head.ecosystem)} — ${head.label}")
|
||||||
|
sb.appendLine("Identity: $trackerId")
|
||||||
|
sb.appendLine("Flagged ${group.size} time(s):")
|
||||||
|
for (a in group) {
|
||||||
|
val where = if (a.lat != null && a.lon != null) "%.5f, %.5f".format(a.lat, a.lon) else "no location"
|
||||||
|
sb.appendLine(" - ${fmt.format(Date(a.timestamp))} : ${a.distinctPlaces} places, peak ${a.peakRssi} dBm, at $where")
|
||||||
|
}
|
||||||
|
val geo = db.sightingDao().allFor(trackerId).filter { it.lat != null && it.lon != null }
|
||||||
|
if (geo.isNotEmpty()) {
|
||||||
|
sb.appendLine(" Seen with you at ${geo.size} location(s):")
|
||||||
|
for (s in geo) {
|
||||||
|
sb.appendLine(" ${fmt.format(Date(s.timestamp))} ${"%.5f, %.5f".format(s.lat, s.lon)} ${s.rssi} dBm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.appendLine()
|
||||||
|
sb.appendLine("=".repeat(52))
|
||||||
|
sb.appendLine("Recorded by VIGIL, an offline anti-tracking app — a log of Bluetooth trackers detected moving with this phone.")
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPX of every geotagged sighting, to open in a maps app. */
|
||||||
|
suspend fun buildGpx(): String {
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
|
||||||
|
sb.appendLine("""<gpx version="1.1" creator="VIGIL" xmlns="http://www.topografix.com/GPX/1/1">""")
|
||||||
|
for (s in db.sightingDao().allGeotagged()) {
|
||||||
|
sb.appendLine(""" <wpt lat="${s.lat}" lon="${s.lon}">""")
|
||||||
|
sb.appendLine(" <time>${fmt.format(Date(s.timestamp))}</time>")
|
||||||
|
sb.appendLine(" <name>${trackerShortName(s.trackerId)} ${s.rssi}dBm</name>")
|
||||||
|
sb.appendLine(" </wpt>")
|
||||||
|
}
|
||||||
|
sb.appendLine("</gpx>")
|
||||||
|
return sb.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val RETENTION_DAYS = 14
|
const val RETENTION_DAYS = 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ecoLabel(name: String): String =
|
||||||
|
runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||||
|
|
||||||
|
private fun trackerShortName(stableId: String): String = when (stableId.substringBefore(':')) {
|
||||||
|
"apple" -> "AppleFindMy"
|
||||||
|
"fmdn" -> "GoogleFMD"
|
||||||
|
"samsung" -> "SmartTag"
|
||||||
|
"tile" -> "Tile"
|
||||||
|
"dult" -> "DULT"
|
||||||
|
else -> "Tracker"
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ data class TrackerEntity(
|
|||||||
val lastAlertMs: Long = 0,
|
val lastAlertMs: Long = 0,
|
||||||
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place
|
// baseline accounting: distinct calendar-days this tracker was seen at an anchor place
|
||||||
val lastAnchorDay: Long = -1,
|
val lastAnchorDay: Long = -1,
|
||||||
val anchorDayCount: Int = 0
|
val anchorDayCount: Int = 0,
|
||||||
|
// display / grounding: latest + peak RSSI and the current co-movement evidence
|
||||||
|
val lastRssi: Int = 0,
|
||||||
|
val peakRssi: Int = -127,
|
||||||
|
val distinctPlaces: Int = 0,
|
||||||
|
val effectiveSightings: Int = 0,
|
||||||
|
// last-seen BLE MAC — needed to GATT-connect for "make it ring"
|
||||||
|
val lastMac: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One sighting of a tracker at one instant, geotagged when a fix is available. */
|
/** One sighting of a tracker at one instant, geotagged when a fix is available. */
|
||||||
@@ -61,6 +68,21 @@ data class PlaceEntity(
|
|||||||
val anchor: Boolean = false
|
val anchor: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A recorded alert — when a tracker first crossed into ALERTING. Kept for the
|
||||||
|
* history list and the evidence export. */
|
||||||
|
@Entity(tableName = "alerts")
|
||||||
|
data class AlertEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val trackerId: String,
|
||||||
|
val ecosystem: String,
|
||||||
|
val label: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val distinctPlaces: Int,
|
||||||
|
val peakRssi: Int,
|
||||||
|
val lat: Double? = null,
|
||||||
|
val lon: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface TrackerDao {
|
interface TrackerDao {
|
||||||
@Query("SELECT * FROM trackers WHERE stableId = :id")
|
@Query("SELECT * FROM trackers WHERE stableId = :id")
|
||||||
@@ -74,6 +96,19 @@ interface TrackerDao {
|
|||||||
|
|
||||||
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
|
@Query("UPDATE trackers SET approved = :approved WHERE stableId = :id")
|
||||||
suspend fun setApproved(id: String, approved: Boolean)
|
suspend fun setApproved(id: String, approved: Boolean)
|
||||||
|
|
||||||
|
// User says a baseline-trusted tag is "not mine" — drop trust and restart the
|
||||||
|
// day clock so it doesn't immediately re-trust.
|
||||||
|
@Query("UPDATE trackers SET baselineSafe = 0, anchorDayCount = 0, lastAnchorDay = -1 WHERE stableId = :id")
|
||||||
|
suspend fun clearBaseline(id: String)
|
||||||
|
|
||||||
|
// Rotated identities pile up (MAC rotation mints a new row each rotation); drop
|
||||||
|
// stale, non-approved rows so the table and the UI list don't grow without bound.
|
||||||
|
@Query("DELETE FROM trackers WHERE lastSeen < :cutoff AND approved = 0")
|
||||||
|
suspend fun pruneStale(cutoff: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM trackers")
|
||||||
|
suspend fun clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -84,8 +119,17 @@ interface SightingDao {
|
|||||||
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
|
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
|
||||||
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
|
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sightings WHERE trackerId = :id ORDER BY timestamp")
|
||||||
|
suspend fun allFor(id: String): List<SightingEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sightings WHERE lat IS NOT NULL ORDER BY timestamp")
|
||||||
|
suspend fun allGeotagged(): List<SightingEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
||||||
suspend fun prune(cutoff: Long)
|
suspend fun prune(cutoff: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM sightings")
|
||||||
|
suspend fun clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -97,15 +141,31 @@ interface PlaceDao {
|
|||||||
suspend fun upsert(place: PlaceEntity)
|
suspend fun upsert(place: PlaceEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AlertDao {
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(alert: AlertEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||||
|
fun observeAll(): Flow<List<AlertEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||||
|
suspend fun all(): List<AlertEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM alerts")
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class, AlertEntity::class],
|
||||||
version = 1,
|
version = 4,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class VigilDatabase : RoomDatabase() {
|
abstract class VigilDatabase : RoomDatabase() {
|
||||||
abstract fun trackerDao(): TrackerDao
|
abstract fun trackerDao(): TrackerDao
|
||||||
abstract fun sightingDao(): SightingDao
|
abstract fun sightingDao(): SightingDao
|
||||||
abstract fun placeDao(): PlaceDao
|
abstract fun placeDao(): PlaceDao
|
||||||
|
abstract fun alertDao(): AlertDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: VigilDatabase? = null
|
@Volatile private var INSTANCE: VigilDatabase? = null
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ class Settings private constructor(context: Context) {
|
|||||||
_sensitivity.value = value
|
_sensitivity.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _onboarded = MutableStateFlow(prefs.getBoolean(KEY_ONBOARDED, false))
|
||||||
|
val onboarded: StateFlow<Boolean> = _onboarded.asStateFlow()
|
||||||
|
fun setOnboarded(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean(KEY_ONBOARDED, value).apply()
|
||||||
|
_onboarded.value = value
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SENSITIVITY = "sensitivity"
|
private const val KEY_SENSITIVITY = "sensitivity"
|
||||||
|
private const val KEY_ONBOARDED = "onboarded"
|
||||||
|
|
||||||
@Volatile private var INSTANCE: Settings? = null
|
@Volatile private var INSTANCE: Settings? = null
|
||||||
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
|
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
|
||||||
|
|||||||
@@ -18,23 +18,29 @@ import org.soulstone.vigil.data.db.PlaceEntity
|
|||||||
object BaselineManager {
|
object BaselineManager {
|
||||||
|
|
||||||
const val DAY_MS = 86_400_000L
|
const val DAY_MS = 86_400_000L
|
||||||
const val ANCHOR_MIN_VISITS = 60
|
const val DWELL_BUCKET_MS = 10 * 60_000L // count at most one "visit" per 10-min dwell window
|
||||||
|
const val ANCHOR_MIN_VISITS = 18 // ~3h cumulative dwell before a cell counts as home/work
|
||||||
const val BASELINE_MIN_DAYS = 3
|
const val BASELINE_MIN_DAYS = 3
|
||||||
|
|
||||||
/** Record a location fix in its cell; returns whether that cell is now an anchor. */
|
/**
|
||||||
|
* Note presence in a cell; returns whether it's an anchor. Crucially a cell
|
||||||
|
* accrues at most ONE visit per [DWELL_BUCKET_MS], so the anchor signal tracks
|
||||||
|
* time actually spent there — not how many trackers or adverts were seen.
|
||||||
|
*
|
||||||
|
* The previous per-sighting count was a real safety flaw: a busy street or a
|
||||||
|
* single chatty tracker could mint a fake "home" in seconds, which would then
|
||||||
|
* baseline-trust (silence alerts for) any tracker seen there — including a real
|
||||||
|
* stalking device.
|
||||||
|
*/
|
||||||
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
|
suspend fun noteLocation(placeDao: PlaceDao, geohash6: String, now: Long): Boolean {
|
||||||
val existing = placeDao.get(geohash6)
|
val existing = placeDao.get(geohash6) ?: run {
|
||||||
val visits = (existing?.visitCount ?: 0) + 1
|
placeDao.upsert(PlaceEntity(geohash6 = geohash6, visitCount = 1, lastSeen = now, anchor = false))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (now - existing.lastSeen < DWELL_BUCKET_MS) return existing.anchor
|
||||||
|
val visits = existing.visitCount + 1
|
||||||
val anchor = visits >= ANCHOR_MIN_VISITS
|
val anchor = visits >= ANCHOR_MIN_VISITS
|
||||||
placeDao.upsert(
|
placeDao.upsert(existing.copy(visitCount = visits, lastSeen = now, anchor = anchor))
|
||||||
PlaceEntity(
|
|
||||||
geohash6 = geohash6,
|
|
||||||
label = existing?.label ?: "",
|
|
||||||
visitCount = visits,
|
|
||||||
lastSeen = now,
|
|
||||||
anchor = anchor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return anchor
|
return anchor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,14 @@ object CoMovementEvaluator {
|
|||||||
val sorted = sightings.sortedBy { it.timestamp }
|
val sorted = sightings.sortedBy { it.timestamp }
|
||||||
|
|
||||||
// Debounce: one effective sighting per DEDUP_MS so a chatty tag at 2s
|
// Debounce: one effective sighting per DEDUP_MS so a chatty tag at 2s
|
||||||
// intervals doesn't trivially clear the count.
|
// intervals doesn't trivially clear the count. lastCounted is NULLABLE and
|
||||||
|
// always counts the first sighting — the old `Long.MIN_VALUE` seed overflowed
|
||||||
|
// (timestamp - Long.MIN_VALUE wraps negative), which pinned `effective` at 0
|
||||||
|
// and made the detector unable to EVER alert. (Caught by unit test.)
|
||||||
var effective = 0
|
var effective = 0
|
||||||
var lastCounted = Long.MIN_VALUE
|
var lastCounted: Long? = null
|
||||||
for (s in sorted) {
|
for (s in sorted) {
|
||||||
if (s.timestamp - lastCounted >= DEDUP_MS) {
|
if (lastCounted == null || s.timestamp - lastCounted >= DEDUP_MS) {
|
||||||
effective++
|
effective++
|
||||||
lastCounted = s.timestamp
|
lastCounted = s.timestamp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,8 +137,11 @@ class PresenceEngine {
|
|||||||
score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20
|
score += (cells.size.toDouble() / (2.0 * K_CELLS)).coerceIn(0.0, 1.0) * 20
|
||||||
score += (if (coherent) 1.0 else 0.4) * 20
|
score += (if (coherent) 1.0 else 0.4) * 20
|
||||||
val s = score.roundToInt()
|
val s = score.roundToInt()
|
||||||
|
// Coherence GATES the top tier: a crowd of many radios at many distances is
|
||||||
|
// incoherent (high RSSI variance), so it can reach PROBABLE at most, never
|
||||||
|
// CONFIRMED — this stops packed-transit false clone alarms.
|
||||||
val tier = when {
|
val tier = when {
|
||||||
s >= 85 -> Tier.CONFIRMED
|
s >= 85 && coherent -> Tier.CONFIRMED
|
||||||
s >= 70 -> Tier.PROBABLE
|
s >= 70 -> Tier.PROBABLE
|
||||||
s >= 40 -> Tier.WATCHING
|
s >= 40 -> Tier.WATCHING
|
||||||
else -> Tier.CLEAR
|
else -> Tier.CLEAR
|
||||||
|
|||||||
@@ -37,3 +37,6 @@ enum class TrackerStatus { SAFE_APPROVED, SAFE_BASELINE, OBSERVED, SUSPICIOUS, A
|
|||||||
|
|
||||||
/** Detection sensitivity — trades time-to-alert against false positives. */
|
/** Detection sensitivity — trades time-to-alert against false positives. */
|
||||||
enum class Sensitivity { HIGH, MEDIUM, LOW }
|
enum class Sensitivity { HIGH, MEDIUM, LOW }
|
||||||
|
|
||||||
|
/** A geotagged point where a tracker was seen, for the in-app co-movement trail. */
|
||||||
|
data class TrailPoint(val lat: Double, val lon: Double)
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
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
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
fun finish(msg: String, g: BluetoothGatt) {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
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 -> {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
// An AirTag self-disconnects (status 19) once it has started ringing.
|
||||||
|
if (cont.isActive) {
|
||||||
|
cont.resume(
|
||||||
|
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
|
||||||
|
"Ringing - listen for the AirTag."
|
||||||
|
else "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 finish("This tracker type doesn't expose a remote-ring service.", g)
|
||||||
|
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||||
|
?: return finish("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)) finish("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 finish("Ring characteristic missing on this tracker.", g)
|
||||||
|
if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||||
|
if (selected == AIRTAG) { finish("Ringing - listen for the AirTag.", g); return }
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) { finish("The tracker refused the ring command.", g); return }
|
||||||
|
// DULT/FindMy confirm via an indication. Wait briefly; if none arrives, the tag
|
||||||
|
// likely isn't separated from its owner (so it won't actually ring).
|
||||||
|
handler.postDelayed({
|
||||||
|
finish("Ring command sent, but the tracker didn't confirm - it only rings when separated from its owner.", g)
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||||
|
finish("Ringing confirmed - listen for the tracker.", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray) {
|
||||||
|
finish("Ringing confirmed - listen for the tracker.", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
runCatching { gatt?.disconnect(); gatt?.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun writeStart(g: BluetoothGatt, ch: BluetoothGattCharacteristic, proto: Proto): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
g.writeCharacteristic(ch, proto.start, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
|
||||||
|
BluetoothGatt.GATT_SUCCESS
|
||||||
|
} else {
|
||||||
|
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
ch.value = proto.start
|
||||||
|
g.writeCharacteristic(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun writeDescriptor(g: BluetoothGatt, desc: BluetoothGattDescriptor, value: ByteArray): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
g.writeDescriptor(desc, value) == BluetoothGatt.GATT_SUCCESS
|
||||||
|
} else {
|
||||||
|
desc.value = value
|
||||||
|
g.writeDescriptor(desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasConnectPermission(context: Context): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
} else true
|
||||||
|
|
||||||
|
private fun uuid(s: String): UUID = UUID.fromString(s)
|
||||||
|
}
|
||||||
@@ -45,11 +45,22 @@ class BleTrackerScanner(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val filters: List<ScanFilter> = buildList {
|
private val filters: List<ScanFilter> = buildList {
|
||||||
// Apple Find My — match presence of Apple manufacturer data.
|
// Apple Find My — match ONLY the offline-finding message type (0x12), not
|
||||||
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build())
|
// every Apple device (iPhones/Watches/AirPods all advertise company 0x004C).
|
||||||
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
|
// Data+mask apply from the first manufacturer-data byte, which is the type.
|
||||||
|
add(
|
||||||
|
ScanFilter.Builder().setManufacturerData(
|
||||||
|
TrackerSignatures.APPLE_COMPANY_ID,
|
||||||
|
byteArrayOf(TrackerSignatures.APPLE_TYPE_FINDMY.toByte()),
|
||||||
|
byteArrayOf(0xFF.toByte())
|
||||||
|
).build()
|
||||||
|
)
|
||||||
|
// FMDN / Samsung / Tile / DULT — match by SERVICE DATA presence, not the
|
||||||
|
// service-UUID list. These carry their payload in service data and may not
|
||||||
|
// advertise the plain UUID-list entry, so setServiceUuid can miss them
|
||||||
|
// entirely (AirGuard uses service-data presence filters for exactly this).
|
||||||
for (uuid in TrackerSignatures.trackerServiceUuids) {
|
for (uuid in TrackerSignatures.trackerServiceUuids) {
|
||||||
add(ScanFilter.Builder().setServiceUuid(uuid).build())
|
add(ScanFilter.Builder().setServiceData(uuid, ByteArray(0)).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,15 +26,16 @@ object TrackerParser {
|
|||||||
|
|
||||||
// --- Apple Find My (manufacturer data, company 0x004C) ---
|
// --- Apple Find My (manufacturer data, company 0x004C) ---
|
||||||
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
|
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
|
||||||
if (d.isNotEmpty() && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
|
// Require the FULL offline-finding frame. After Android strips the company
|
||||||
// Android strips the 2-byte company id, so d = [type(0x12),
|
// id, d = [type, len, status, key(22), keyTopBits, hint] (~27 bytes). Short
|
||||||
// length(0x19), status, key(22), keyTopBits, hint]. The maintained
|
// "nearby" frames carry no key — ignore them rather than fabricate a shared
|
||||||
// bit lives in the status byte (index 2): set => near owner,
|
// identity from the type/status bytes, which merged many distinct devices
|
||||||
// cleared/absent => treat as separated.
|
// into one phantom "tracker" and misclassified it as separated.
|
||||||
val status = if (d.size > 2) d[2].toInt() and 0xFF else 0
|
if (d.size >= 25 && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
|
||||||
|
val status = d[2].toInt() and 0xFF // maintained bit set => near owner
|
||||||
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
|
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
|
||||||
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
|
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
|
||||||
val keyBytes = if (d.size >= 25) d.copyOfRange(3, 25) else d
|
val keyBytes = d.copyOfRange(3, 25)
|
||||||
return TrackerObservation(
|
return TrackerObservation(
|
||||||
stableId = "apple:" + toHex(keyBytes),
|
stableId = "apple:" + toHex(keyBytes),
|
||||||
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
|
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import android.os.VibrationEffect
|
|||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -49,6 +50,10 @@ class ScanService : LifecycleService() {
|
|||||||
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
|
private const val NOTIFICATION_ID = 0x5161 // "VIGIL"
|
||||||
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
|
private const val PRUNE_INTERVAL_MS = 6 * 3_600_000L
|
||||||
private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L
|
private const val CLONE_COOLDOWN_MS = 2 * 3_600_000L
|
||||||
|
private const val MIN_RECORD_INTERVAL_MS = 15_000L // per-device DB throttle
|
||||||
|
private const val ALERT_CHANNEL_ID = "vigil_alerts"
|
||||||
|
private const val ALERT_NOTIF_ID = 0x5162
|
||||||
|
private const val CLONE_NOTIF_ID = 0x5163
|
||||||
|
|
||||||
const val ACTION_START = "org.soulstone.vigil.action.START"
|
const val ACTION_START = "org.soulstone.vigil.action.START"
|
||||||
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
|
const val ACTION_STOP = "org.soulstone.vigil.action.STOP"
|
||||||
@@ -56,6 +61,16 @@ class ScanService : LifecycleService() {
|
|||||||
private val _running = MutableStateFlow(false)
|
private val _running = MutableStateFlow(false)
|
||||||
val running: StateFlow<Boolean> = _running.asStateFlow()
|
val running: StateFlow<Boolean> = _running.asStateFlow()
|
||||||
|
|
||||||
|
// Live RSSI stream for the "find it" hot/cold screen. The UI sets a target
|
||||||
|
// stableId; every matching advert (not the throttled DB path) updates this.
|
||||||
|
@Volatile private var finderTarget: String? = null
|
||||||
|
private val _finderRssi = MutableStateFlow<Int?>(null)
|
||||||
|
val finderRssi: StateFlow<Int?> = _finderRssi.asStateFlow()
|
||||||
|
fun setFinderTarget(id: String?) {
|
||||||
|
finderTarget = id
|
||||||
|
_finderRssi.value = null
|
||||||
|
}
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
|
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@@ -75,7 +90,8 @@ class ScanService : LifecycleService() {
|
|||||||
private lateinit var location: LocationProvider
|
private lateinit var location: LocationProvider
|
||||||
private lateinit var scanner: BleTrackerScanner
|
private lateinit var scanner: BleTrackerScanner
|
||||||
private val presence = PresenceEngine()
|
private val presence = PresenceEngine()
|
||||||
private var lastCloneAlertMs = 0L
|
private val lastRecordedAt = ConcurrentHashMap<String, Long>()
|
||||||
|
@Volatile private var lastCloneAlertMs = 0L
|
||||||
private var pruneJob: Job? = null
|
private var pruneJob: Job? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -109,8 +125,10 @@ class ScanService : LifecycleService() {
|
|||||||
pruneJob?.cancel()
|
pruneJob?.cancel()
|
||||||
pruneJob = lifecycleScope.launch {
|
pruneJob = lifecycleScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(PRUNE_INTERVAL_MS)
|
// Prune first (frequent restarts previously meant the 6h-delayed
|
||||||
|
// prune rarely ran, so stale rotated identities accumulated).
|
||||||
runCatching { repo.prune() }.onFailure { Log.w(TAG, "prune failed: ${it.message}") }
|
runCatching { repo.prune() }.onFailure { Log.w(TAG, "prune failed: ${it.message}") }
|
||||||
|
delay(PRUNE_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +136,9 @@ class ScanService : LifecycleService() {
|
|||||||
private fun onObservation(obs: TrackerObservation) {
|
private fun onObservation(obs: TrackerObservation) {
|
||||||
val fix = location.location.value
|
val fix = location.location.value
|
||||||
|
|
||||||
|
// Live RSSI for the "find it" screen — every advert, ahead of any throttle.
|
||||||
|
if (obs.stableId == finderTarget) _finderRssi.value = obs.rssi
|
||||||
|
|
||||||
// Rotation-clone presence engine (problem #1) — fed synchronously so its
|
// Rotation-clone presence engine (problem #1) — fed synchronously so its
|
||||||
// streaming state stays ordered. Only a CONFIRMED verdict raises a user
|
// streaming state stays ordered. Only a CONFIRMED verdict raises a user
|
||||||
// alert; softer tiers stay silent until validated on real captures.
|
// alert; softer tiers stay silent until validated on real captures.
|
||||||
@@ -131,7 +152,16 @@ class ScanService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporal co-movement (identity path) — persisted + evaluated off-thread.
|
// Temporal co-movement (identity path). CALLBACK_TYPE_ALL_MATCHES fires on
|
||||||
|
// every advert (many/sec), so throttle the persisted path per device: the
|
||||||
|
// presence engine above still sees every advert, but the DB records at most
|
||||||
|
// one sighting per device per MIN_RECORD_INTERVAL_MS. Keeps the count
|
||||||
|
// meaningful and avoids hammering the database.
|
||||||
|
val last = lastRecordedAt[obs.stableId]
|
||||||
|
if (last != null && obs.timestampMs - last < MIN_RECORD_INTERVAL_MS) return
|
||||||
|
lastRecordedAt[obs.stableId] = obs.timestampMs
|
||||||
|
if (lastRecordedAt.size > 4096) lastRecordedAt.clear() // guard vs MAC-rotation growth
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
|
repo.record(obs, fix?.latitude, fix?.longitude, settings.sensitivity.value)
|
||||||
@@ -141,10 +171,13 @@ class ScanService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun end() {
|
private fun end() {
|
||||||
if (!_running.value) return
|
// No early-return guard: all stops below are idempotent, and begin() calls
|
||||||
|
// end() on a failed scanner start (when _running was never set) to make sure
|
||||||
|
// the location updates and foreground notification are released, not leaked.
|
||||||
_running.value = false
|
_running.value = false
|
||||||
scanner.stop()
|
scanner.stop()
|
||||||
location.stop()
|
location.stop()
|
||||||
|
lastRecordedAt.clear()
|
||||||
pruneJob?.cancel(); pruneJob = null
|
pruneJob?.cancel(); pruneJob = null
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
@@ -177,9 +210,9 @@ class ScanService : LifecycleService() {
|
|||||||
val n = buildNotification(
|
val n = buildNotification(
|
||||||
title = "Tracker following you",
|
title = "Tracker following you",
|
||||||
text = "${tracker.label} has been moving with you",
|
text = "${tracker.label} has been moving with you",
|
||||||
high = true
|
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
|
||||||
)
|
)
|
||||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, n)
|
getSystemService(NotificationManager::class.java)?.notify(ALERT_NOTIF_ID, n)
|
||||||
vibrate()
|
vibrate()
|
||||||
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
|
Log.w(TAG, "ALERT: ${tracker.stableId} (${tracker.ecosystem})")
|
||||||
}
|
}
|
||||||
@@ -188,9 +221,9 @@ class ScanService : LifecycleService() {
|
|||||||
val n = buildNotification(
|
val n = buildNotification(
|
||||||
title = "Possible hidden tracker",
|
title = "Possible hidden tracker",
|
||||||
text = "A rotating-ID tracker appears to be moving with you",
|
text = "A rotating-ID tracker appears to be moving with you",
|
||||||
high = true
|
high = true, channelId = ALERT_CHANNEL_ID, ongoing = false
|
||||||
)
|
)
|
||||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID + 1, n)
|
getSystemService(NotificationManager::class.java)?.notify(CLONE_NOTIF_ID, n)
|
||||||
vibrate()
|
vibrate()
|
||||||
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
|
Log.w(TAG, "CLONE ALERT: rotating-id presence confirmed")
|
||||||
}
|
}
|
||||||
@@ -208,7 +241,13 @@ class ScanService : LifecycleService() {
|
|||||||
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
@Suppress("DEPRECATION") getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(title: String, text: String, high: Boolean): Notification {
|
private fun buildNotification(
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
high: Boolean,
|
||||||
|
channelId: String = CHANNEL_ID,
|
||||||
|
ongoing: Boolean = true
|
||||||
|
): Notification {
|
||||||
val openIntent = Intent(this, MainActivity::class.java).apply {
|
val openIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
@@ -216,31 +255,48 @@ class ScanService : LifecycleService() {
|
|||||||
this, 0, openIntent,
|
this, 0, openIntent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, channelId)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||||
.setOngoing(true)
|
.setOngoing(ongoing)
|
||||||
|
.setAutoCancel(!ongoing)
|
||||||
.setContentIntent(pi)
|
.setContentIntent(pi)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(if (high) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_SERVICE)
|
||||||
.setPriority(if (high) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW)
|
.setPriority(if (high) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW)
|
||||||
.setOnlyAlertOnce(!high)
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val mgr = getSystemService(NotificationManager::class.java) ?: return
|
val mgr = getSystemService(NotificationManager::class.java) ?: return
|
||||||
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
|
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
getString(R.string.notification_channel_name),
|
getString(R.string.notification_channel_name),
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
).apply {
|
).apply {
|
||||||
description = getString(R.string.notification_channel_desc)
|
description = getString(R.string.notification_channel_desc)
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// Alerts get their OWN high-importance channel so a real "following you"
|
||||||
|
// warning actually makes noise + a heads-up banner. On Android 8+ channel
|
||||||
|
// importance overrides per-notification priority, so the ongoing LOW status
|
||||||
|
// channel could never sound an alert.
|
||||||
|
if (mgr.getNotificationChannel(ALERT_CHANNEL_ID) == null) {
|
||||||
|
mgr.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
ALERT_CHANNEL_ID,
|
||||||
|
"Tracker alerts",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "A tracker appears to be following you"
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.soulstone.vigil.data.db.AlertEntity
|
||||||
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
|
|
||||||
|
/** Log of past alerts, with a one-tap evidence export (report + GPX). */
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(
|
||||||
|
alerts: List<AlertEntity>,
|
||||||
|
onExport: () -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text("Alert history", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onExport, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Share, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Export evidence")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onClear, enabled = alerts.isNotEmpty()) { Text("Clear") }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"Export saves a timestamped report and a GPX track (open it in a maps app). Files stay on your phone until you share them.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
if (alerts.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No alerts yet. When a tracker is confirmed following you, it'll be logged here.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(alerts, key = { it.id }) { a -> AlertRow(a) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlertRow(a: AlertEntity) {
|
||||||
|
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||||
|
Column(Modifier.padding(14.dp)) {
|
||||||
|
Text(ecoName(a.ecosystem), fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
"${a.distinctPlaces} places · peak ${a.peakRssi} dBm · ${rel(a.timestamp)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ecoName(name: String) = runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||||
|
private fun rel(ts: Long) = DateUtils.getRelativeTimeSpanString(ts, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
package org.soulstone.vigil.ui
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
import android.text.format.DateUtils
|
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 androidx.compose.ui.res.painterResource
|
||||||
|
import org.soulstone.vigil.service.ScanService
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -19,12 +31,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bluetooth
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
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.GppMaybe
|
||||||
|
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Radar
|
import androidx.compose.material.icons.filled.Radar
|
||||||
import androidx.compose.material.icons.filled.Shield
|
import androidx.compose.material.icons.filled.Shield
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -33,9 +49,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -44,11 +63,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -58,7 +79,9 @@ import org.soulstone.vigil.BuildConfig
|
|||||||
import org.soulstone.vigil.data.db.TrackerEntity
|
import org.soulstone.vigil.data.db.TrackerEntity
|
||||||
import org.soulstone.vigil.model.Sensitivity
|
import org.soulstone.vigil.model.Sensitivity
|
||||||
import org.soulstone.vigil.model.TrackerEcosystem
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
|
import org.soulstone.vigil.R
|
||||||
import org.soulstone.vigil.model.TrackerStatus
|
import org.soulstone.vigil.model.TrackerStatus
|
||||||
|
import org.soulstone.vigil.model.TrailPoint
|
||||||
import org.soulstone.vigil.ui.theme.VigilGreen
|
import org.soulstone.vigil.ui.theme.VigilGreen
|
||||||
import org.soulstone.vigil.ui.theme.VigilPeach
|
import org.soulstone.vigil.ui.theme.VigilPeach
|
||||||
import org.soulstone.vigil.ui.theme.VigilRed
|
import org.soulstone.vigil.ui.theme.VigilRed
|
||||||
@@ -72,11 +95,21 @@ fun MainScreen(
|
|||||||
permissionMessage: String?,
|
permissionMessage: String?,
|
||||||
onStartStop: () -> Unit,
|
onStartStop: () -> Unit,
|
||||||
onSetSensitivity: (Sensitivity) -> Unit,
|
onSetSensitivity: (Sensitivity) -> Unit,
|
||||||
onApprove: (String, Boolean) -> Unit
|
onApprove: (String, Boolean) -> Unit,
|
||||||
|
onDistrust: (String) -> Unit,
|
||||||
|
onRing: (TrackerEntity) -> Unit,
|
||||||
|
onClearAll: () -> Unit,
|
||||||
|
onOpenSafety: () -> Unit,
|
||||||
|
onOpenHistory: () -> Unit,
|
||||||
|
loadTrail: suspend (String) -> List<TrailPoint>
|
||||||
) {
|
) {
|
||||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
|
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
|
|
||||||
val active = trackers.filter { !isTrusted(it) }.sortedByDescending { riskRank(it) }
|
val now = System.currentTimeMillis()
|
||||||
|
val active = trackers
|
||||||
|
.filter { !isTrusted(it) && now - it.lastSeen < ACTIVE_WINDOW_MS }
|
||||||
|
.sortedByDescending { riskRank(it) }
|
||||||
val trusted = trackers.filter { isTrusted(it) }
|
val trusted = trackers.filter { isTrusted(it) }
|
||||||
val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
|
val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
|
||||||
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
|
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
|
||||||
@@ -87,12 +120,39 @@ fun MainScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.Shield, null, tint = MaterialTheme.colorScheme.primary)
|
Image(
|
||||||
|
painterResource(R.drawable.vigil_logo),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(26.dp)
|
||||||
|
)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("VIGIL", fontWeight = FontWeight.Bold)
|
Text("VIGIL", fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenHistory) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.History,
|
||||||
|
contentDescription = "Alert history",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onOpenSafety) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.HealthAndSafety,
|
||||||
|
contentDescription = "Safety & help",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Lock, null,
|
Icons.Filled.Lock, null,
|
||||||
@@ -159,14 +219,14 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(active, key = { it.stableId }) { t ->
|
items(active, key = { it.stableId }) { t ->
|
||||||
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
|
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trusted.isNotEmpty()) {
|
if (trusted.isNotEmpty()) {
|
||||||
item { SectionHeader("Trusted (${trusted.size})") }
|
item { SectionHeader("Trusted (${trusted.size})") }
|
||||||
items(trusted, key = { it.stableId }) { t ->
|
items(trusted, key = { it.stableId }) { t ->
|
||||||
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
|
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove, onDistrust = onDistrust)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +248,25 @@ fun MainScreen(
|
|||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
) {
|
) {
|
||||||
TrackerDetail(t, onApprove = { id, a -> onApprove(id, a); detail = null })
|
TrackerDetail(
|
||||||
|
t,
|
||||||
|
onApprove = { id, a -> onApprove(id, a); detail = null },
|
||||||
|
onDistrust = { id -> onDistrust(id); detail = null },
|
||||||
|
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
||||||
|
onRing = onRing,
|
||||||
|
onSafety = { onOpenSafety(); detail = null },
|
||||||
|
loadTrail = loadTrail
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finding?.let { dev ->
|
||||||
|
FinderScreen(
|
||||||
|
dev,
|
||||||
|
onRing = onRing,
|
||||||
|
onClose = { ScanService.setFinderTarget(null); finding = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -283,7 +359,12 @@ private fun SectionHeader(text: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (String, Boolean) -> Unit) {
|
private fun TrackerCard(
|
||||||
|
t: TrackerEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onApprove: (String, Boolean) -> Unit,
|
||||||
|
onDistrust: (String) -> Unit
|
||||||
|
) {
|
||||||
val status = statusOf(t)
|
val status = statusOf(t)
|
||||||
val accent = statusColor(status)
|
val accent = statusColor(status)
|
||||||
Card(
|
Card(
|
||||||
@@ -302,7 +383,7 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
|
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
|
||||||
Text(
|
Text(
|
||||||
"${statusLabel(status)} · ${t.sightingCount} sightings · ${relative(t.lastSeen)}",
|
"${statusLabel(status)} · ${t.distinctPlaces} places · ${t.lastRssi} dBm · ${relative(t.lastSeen)}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -310,10 +391,8 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
|
|||||||
when (status) {
|
when (status) {
|
||||||
TrackerStatus.SAFE_APPROVED ->
|
TrackerStatus.SAFE_APPROVED ->
|
||||||
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
|
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") }
|
||||||
TrackerStatus.SAFE_BASELINE -> Icon(
|
TrackerStatus.SAFE_BASELINE ->
|
||||||
Icons.Filled.Verified, null,
|
TextButton(onClick = { onDistrust(t.stableId) }) { Text("Not mine") }
|
||||||
tint = VigilGreen, modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's mine") }
|
else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's mine") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +400,15 @@ private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) {
|
private fun TrackerDetail(
|
||||||
|
t: TrackerEntity,
|
||||||
|
onApprove: (String, Boolean) -> Unit,
|
||||||
|
onDistrust: (String) -> Unit,
|
||||||
|
onFind: (TrackerEntity) -> Unit,
|
||||||
|
onRing: (TrackerEntity) -> Unit,
|
||||||
|
onSafety: () -> Unit,
|
||||||
|
loadTrail: suspend (String) -> List<TrailPoint>
|
||||||
|
) {
|
||||||
val status = statusOf(t)
|
val status = statusOf(t)
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -331,13 +418,28 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
|
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
|
||||||
|
if (status == TrackerStatus.ALERTING || status == TrackerStatus.SUSPICIOUS) {
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onSafety,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.HealthAndSafety, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("What should I do?", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
DetailRow("Sightings", t.sightingCount.toString())
|
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
|
||||||
|
DetailRow("Distinct places", t.distinctPlaces.toString())
|
||||||
|
DetailRow("Co-movement sightings", t.effectiveSightings.toString())
|
||||||
|
DetailRow("Adverts logged", t.sightingCount.toString())
|
||||||
DetailRow("First seen", relative(t.firstSeen))
|
DetailRow("First seen", relative(t.firstSeen))
|
||||||
DetailRow("Last seen", relative(t.lastSeen))
|
DetailRow("Last seen", relative(t.lastSeen))
|
||||||
if (t.anchorDayCount > 0) {
|
if (t.anchorDayCount > 0) {
|
||||||
DetailRow("Days seen at your places", "${t.anchorDayCount} (trusted at 3)")
|
DetailRow("Days at your places", "${t.anchorDayCount} / 3 to trust")
|
||||||
}
|
}
|
||||||
DetailRow("Identity", t.stableId.take(22) + "…")
|
DetailRow("Identity", t.stableId.take(22) + "…")
|
||||||
|
|
||||||
@@ -347,16 +449,70 @@ private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val trail by produceState(initialValue = emptyList<TrailPoint>(), t.stableId) { value = loadTrail(t.stableId) }
|
||||||
|
if (trail.size >= 2) {
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Text("Where it's moved with you", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
TrailView(
|
||||||
|
trail,
|
||||||
|
Modifier.fillMaxWidth().height(150.dp).clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"A schematic of the ${trail.size} points it was seen — not a real map. Use Export for that.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
if (status == TrackerStatus.SAFE_APPROVED) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
|
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
|
||||||
Text("Remove from approved")
|
Icon(Icons.Filled.Sensors, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Find it")
|
||||||
}
|
}
|
||||||
} else if (status != TrackerStatus.SAFE_BASELINE) {
|
FilledTonalButton(onClick = { onRing(t) }, modifier = Modifier.weight(1f)) {
|
||||||
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
|
Icon(Icons.Filled.NotificationsActive, null, modifier = Modifier.size(18.dp))
|
||||||
Text("This is mine — stop alerting")
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Ring it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var showInfo by remember { mutableStateOf(false) }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(onClick = { showInfo = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Who owns it?")
|
||||||
|
}
|
||||||
|
if (showInfo) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showInfo = false },
|
||||||
|
confirmButton = { TextButton(onClick = { showInfo = false }) { Text("Got it") } },
|
||||||
|
title = { Text("Who owns this tracker?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Tap the tracker against the top of your phone (NFC). If it's an AirTag or a DULT tag in lost mode, your phone opens a page with the owner's masked phone or email. VIGIL stays offline - the tag's NFC hands the link to your browser."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
when (status) {
|
||||||
|
TrackerStatus.SAFE_APPROVED ->
|
||||||
|
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Remove from approved")
|
||||||
|
}
|
||||||
|
TrackerStatus.SAFE_BASELINE ->
|
||||||
|
Button(onClick = { onDistrust(t.stableId) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Not mine — re-check this tracker")
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("This is mine — stop alerting")
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,8 +525,116 @@ 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offline schematic of a tracker's geotagged sightings — points + path, scaled
|
||||||
|
* to fit. Not a real map (VIGIL has no network); the GPX export opens in one. */
|
||||||
|
@Composable
|
||||||
|
private fun TrailView(points: List<TrailPoint>, modifier: Modifier = Modifier) {
|
||||||
|
val line = Color(0xFF89B4FA)
|
||||||
|
val startC = Color(0xFFA6E3A1)
|
||||||
|
val endC = Color(0xFFFAB387)
|
||||||
|
Canvas(modifier) {
|
||||||
|
if (points.size < 2) return@Canvas
|
||||||
|
val pad = 18f
|
||||||
|
val minLat = points.minOf { it.lat }; val maxLat = points.maxOf { it.lat }
|
||||||
|
val minLon = points.minOf { it.lon }; val maxLon = points.maxOf { it.lon }
|
||||||
|
val rLat = (maxLat - minLat).let { if (it > 1e-9) it else 1e-9 }
|
||||||
|
val rLon = (maxLon - minLon).let { if (it > 1e-9) it else 1e-9 }
|
||||||
|
val w = size.width - 2 * pad
|
||||||
|
val h = size.height - 2 * pad
|
||||||
|
val pts = points.map { p ->
|
||||||
|
Offset(
|
||||||
|
pad + ((p.lon - minLon) / rLon).toFloat() * w,
|
||||||
|
pad + (1f - ((p.lat - minLat) / rLat).toFloat()) * h
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (i in 0 until pts.size - 1) {
|
||||||
|
drawLine(line.copy(alpha = 0.55f), pts[i], pts[i + 1], strokeWidth = 3f)
|
||||||
|
}
|
||||||
|
pts.forEachIndexed { i, o ->
|
||||||
|
val c = if (i == 0) startC else if (i == pts.lastIndex) endC else line
|
||||||
|
drawCircle(c, radius = if (i == 0 || i == pts.lastIndex) 6f else 4f, center = o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- pure helpers ----------------------------------------------------------
|
// ---- pure helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
|
||||||
|
|
||||||
private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
|
private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe
|
||||||
|
|
||||||
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) {
|
private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.soulstone.vigil.R
|
||||||
|
|
||||||
|
/** First-run: what VIGIL does, its offline promise, and why it needs its permissions. */
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScreen(onFinish: () -> Unit) {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Image(painterResource(R.drawable.vigil_logo), null, modifier = Modifier.size(76.dp))
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Know what's been following you.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
|
||||||
|
Point(
|
||||||
|
Icons.Filled.Sensors,
|
||||||
|
"Finds what follows you",
|
||||||
|
"Detects AirTags, Tile, Samsung SmartTags and Find My trackers that travel with you over time — not just whatever happens to be nearby."
|
||||||
|
)
|
||||||
|
Point(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
"Stays on your phone",
|
||||||
|
"No account, no internet, no telemetry. Everything VIGIL learns lives on this device and never leaves it."
|
||||||
|
)
|
||||||
|
Point(
|
||||||
|
Icons.Filled.LocationOn,
|
||||||
|
"Why it needs permissions",
|
||||||
|
"Bluetooth to hear trackers, location to tell one is moving with you across places, and notifications to warn you. That's the whole reason it asks."
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(onClick = onFinish, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||||
|
Text("Get started", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Point(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||||
|
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(26.dp).padding(top = 2.dp))
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Text(title, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.soulstone.vigil.ui.theme.VigilRed
|
||||||
|
|
||||||
|
/** Survivor-centred guidance shown when a tracker may be following you. Every
|
||||||
|
* action hands off to the dialer / messages / browser — VIGIL stays offline. */
|
||||||
|
@Composable
|
||||||
|
fun SafetyScreen(onBack: () -> Unit) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
fun go(intent: Intent) = runCatching { ctx.startActivity(intent) }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text("If a tracker is following you", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"VIGIL is a detection tool, not a substitute for professional help. If you're in immediate danger, call emergency services.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Step(1, "Get somewhere safe", "If you feel in danger, head to a public place or someone you trust before doing anything else.")
|
||||||
|
Step(2, "Think before you remove it", "Turning a tracker off can alert whoever placed it and escalate the risk. If you might be in danger, consider documenting it and getting help first.")
|
||||||
|
Step(3, "Document what you can", "Save when and where it was seen (use Export), and photograph the device if you find it. A dated record helps police and advocates.")
|
||||||
|
Step(4, "Report it", "Contact local police — 911 if you're in immediate danger. If this involves a partner or ex, a domestic-violence advocate can help you make a safety plan.")
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text("Get help", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:911"))) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Call, null); Spacer(Modifier.size(8.dp)); Text("Call 911 (emergency)", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("U.S. National Domestic Violence Hotline", fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("Free, confidential, 24/7 — help with stalking and abuse, including safety planning.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18007997233"))) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Call")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { go(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:88788")).putExtra("sms_body", "START")) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Chat, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Text START")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18554842846"))) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("VictimConnect — 1-855-484-2846 (stalking victims)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/what-to-do-if-you-are-being-stalked/"))) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("What to do if you're being stalked (SPARC)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"These open your phone, messages, or browser — VIGIL itself never connects to the internet. Resources shown are U.S.; check what's available where you are.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Step(n: Int, title: String, body: String) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 9.dp)) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(26.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { Text("$n", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) }
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(title, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
<!-- eye outline -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#1FAA59"
|
android:pathData="M26,54 Q54,30 82,54 Q54,78 26,54 Z"
|
||||||
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
|
android:strokeColor="#89B4FA"
|
||||||
|
android:strokeWidth="4.5"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<!-- radar iris — outer ring -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#F4F6FA"
|
android:pathData="M54,54 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0"
|
||||||
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,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>
|
</vector>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- VIGIL wordmark glyph (Radar Eye), tight-cropped for in-app use (top bar). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<path
|
||||||
|
android:pathData="M4,24 Q24,8 44,24 Q24,40 4,24 Z"
|
||||||
|
android:strokeColor="#89B4FA"
|
||||||
|
android:strokeWidth="3"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M24,24 m-7.5,0 a7.5,7.5 0 1,0 15,0 a7.5,7.5 0 1,0 -15,0"
|
||||||
|
android:strokeColor="#89B4FA"
|
||||||
|
android:strokeWidth="2.4"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M24,24 m-4.4,0 a4.4,4.4 0 1,0 8.8,0 a4.4,4.4 0 1,0 -8.8,0"
|
||||||
|
android:strokeColor="#B4BEFE"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000" />
|
||||||
|
<path
|
||||||
|
android:pathData="M24,24 m-2.2,0 a2.2,2.2 0 1,0 4.4,0 a2.2,2.2 0 1,0 -4.4,0"
|
||||||
|
android:fillColor="#A6E3A1" />
|
||||||
|
</vector>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="exports" path="exports/" />
|
||||||
|
</paths>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* OVERWATCH landing — count-up stats + scroll reveal. No network, no deps. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/* count-up numbers when the stats row scrolls into view */
|
||||||
|
function countUp(el) {
|
||||||
|
const target = parseInt(el.dataset.target, 10);
|
||||||
|
if (isNaN(target)) return;
|
||||||
|
if (!target || reduceMotion) { el.textContent = String(target); return; }
|
||||||
|
const dur = 1100, start = performance.now();
|
||||||
|
(function tick(now) {
|
||||||
|
const p = Math.min(1, (now - start) / dur);
|
||||||
|
el.textContent = String(Math.round(target * (1 - Math.pow(1 - p, 3))));
|
||||||
|
if (p < 1) requestAnimationFrame(tick);
|
||||||
|
})(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsRow = document.getElementById('stats-row');
|
||||||
|
if (statsRow) {
|
||||||
|
const nums = statsRow.querySelectorAll('.num[data-target]');
|
||||||
|
if ('IntersectionObserver' in window && !reduceMotion) {
|
||||||
|
const io = new IntersectionObserver((entries, obs) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) { nums.forEach(countUp); obs.disconnect(); }
|
||||||
|
});
|
||||||
|
}, { threshold: 0.4 });
|
||||||
|
io.observe(statsRow);
|
||||||
|
} else {
|
||||||
|
nums.forEach((el) => { el.textContent = el.dataset.target; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scroll-triggered reveal */
|
||||||
|
const reveals = document.querySelectorAll('.reveal');
|
||||||
|
if (!('IntersectionObserver' in window) || reduceMotion) {
|
||||||
|
reveals.forEach((el) => el.classList.add('in'));
|
||||||
|
} else {
|
||||||
|
const ro = new IntersectionObserver((entries, obs) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) { e.target.classList.add('in'); obs.unobserve(e.target); }
|
||||||
|
});
|
||||||
|
}, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });
|
||||||
|
reveals.forEach((el) => ro.observe(el));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,407 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VIGIL — what's been following you?</title>
|
||||||
|
<meta name="description" content="A native Android app that watches for personal item-trackers — AirTags, Tile, Samsung SmartTags, Google Find My — travelling with you over time. Fully offline, no INTERNET permission. The temporal counterpart to OVERWATCH.">
|
||||||
|
<meta property="og:title" content="VIGIL — what's been following you?">
|
||||||
|
<meta property="og:description" content="Android app that flags AirTags, Tile, SmartTags & Find My trackers travelling WITH you over time — even rotating-key clones. Fully offline, listens only.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://vigil.netslum.io/">
|
||||||
|
<meta property="og:image" content="https://vigil.netslum.io/og.png">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:image" content="https://vigil.netslum.io/og.png">
|
||||||
|
<meta name="theme-color" content="#07070d">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="logo.svg">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="style.css?v=2">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="bg-mesh" aria-hidden="true">
|
||||||
|
<div class="mesh-blob mesh-blob-1"></div>
|
||||||
|
<div class="mesh-blob mesh-blob-2"></div>
|
||||||
|
<div class="mesh-blob mesh-blob-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="container nav-inner">
|
||||||
|
<a class="nav-brand" href="#">
|
||||||
|
<img class="nav-logo" src="logo.svg" width="30" height="30" alt="" aria-hidden="true">
|
||||||
|
VIGIL
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#detects">What it detects</a>
|
||||||
|
<a href="#decides">How it decides</a>
|
||||||
|
<a href="#screens">Screens</a>
|
||||||
|
<a href="#clones">Clones</a>
|
||||||
|
<a href="#download">Download</a>
|
||||||
|
<a class="nav-github" href="https://github.com/KaraZajac/VIGIL" aria-label="GitHub">
|
||||||
|
<svg height="18" viewBox="0 0 16 16" width="18" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ──────────────── HERO ──────────────── -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="container hero-inner">
|
||||||
|
<div class="hero-eyebrow">
|
||||||
|
<span class="dot dot-pulse"></span>
|
||||||
|
v0.1.7 · Android · prototype
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title">
|
||||||
|
<span class="display-wordmark">VIGIL</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-tag">
|
||||||
|
<strong>What's been following you?</strong> A native Android app that
|
||||||
|
watches for personal item-trackers — AirTags, Tile, Samsung SmartTags,
|
||||||
|
Google Find My — that are <strong>travelling with you over time</strong>.
|
||||||
|
<span class="hero-tag-pop">A tracker being near you means nothing. The signal is persistence.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="phone-hero">
|
||||||
|
<img class="phone" src="img/vigil-main.png?v=2" width="1096" height="2280"
|
||||||
|
alt="VIGIL main screen: a green "You're clear — nothing has been following you" card, a Stop Watching button, and a High/Medium/Low sensitivity selector." loading="eager">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-row" id="stats-row">
|
||||||
|
<div class="stat-chip"><span class="num" data-target="5">0</span><span>tracker ecosystems</span></div>
|
||||||
|
<div class="stat-chip stat-vfy"><span class="num" data-target="3">0</span><span>escalation tiers</span></div>
|
||||||
|
<div class="stat-chip"><span class="num" data-target="0">0</span><span>internet permission</span></div>
|
||||||
|
<div class="stat-chip"><span class="num">v0.1.7</span><span>latest APK</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="btn btn-primary" href="https://github.com/KaraZajac/VIGIL/releases/latest">
|
||||||
|
↓ Download APK
|
||||||
|
</a>
|
||||||
|
<a class="btn" href="#clones">The clone problem</a>
|
||||||
|
<a class="btn btn-ghost" href="https://github.com/KaraZajac/VIGIL">
|
||||||
|
<svg height="16" viewBox="0 0 16 16" width="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||||
|
Source on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hero-warn">Prototype / work in progress. Debug-signed APK — sideload; no Play Store. <a href="https://github.com/KaraZajac/VIGIL">Read the README.</a></p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ──────────────── OVERWATCH SIBLING ──────────────── -->
|
||||||
|
<section class="section section-callout reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="callout">
|
||||||
|
<div class="callout-mark">🛰</div>
|
||||||
|
<div>
|
||||||
|
<h3>The temporal counterpart to <a href="https://overwatch.netslum.io">OVERWATCH</a></h3>
|
||||||
|
<p>
|
||||||
|
<strong>OVERWATCH is spatial</strong> — what surveillance is watching
|
||||||
|
<em>this place</em>, right now. <strong>VIGIL is temporal</strong> —
|
||||||
|
what has been with <em>you</em>, across time and places. VIGIL reuses
|
||||||
|
OVERWATCH's proven passive-scanning stack and adds a persistent
|
||||||
|
on-device store to reason about a tracker's history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── WHAT IT DETECTS ──────────────── -->
|
||||||
|
<section id="detects" class="section section-bento reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">what it detects</span>
|
||||||
|
<h2>Five ecosystems. One tell: separated from its owner.</h2>
|
||||||
|
<p class="lead">VIGIL recognises each BLE wire format and, where the
|
||||||
|
ecosystem signals it, filters to the <strong>separated-from-owner</strong>
|
||||||
|
state — the only state in which a <em>following</em> tracker is even
|
||||||
|
detectable. Chipolo, Pebblebee, eufy and the rest inherit whichever
|
||||||
|
network their SKU joined, so VIGIL detects the <em>network</em>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bento">
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">🍎</div>
|
||||||
|
<h3>Apple Find My / AirTag</h3>
|
||||||
|
<p>Mfg data, company <code>0x004C</code>, type <code>0x12</code>. Separated when the "maintained" status bit is cleared. ~24 h re-link window.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">🟢</div>
|
||||||
|
<h3>Google Find My Device</h3>
|
||||||
|
<p>Service data <code>0xFEAA</code>, frame <code>0x40/0x41</code>. Frame <code>0x41</code> is the cleartext separated state.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">🔵</div>
|
||||||
|
<h3>Samsung Galaxy SmartTag</h3>
|
||||||
|
<p>Service data <code>0xFD5A</code>; the state byte flags lost / overmature-lost — the following state.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">🟩</div>
|
||||||
|
<h3>Tile</h3>
|
||||||
|
<p>Service data <code>0xFEED / 0xFEEC</code>. No separated signal and a static MAC — always findable, indefinitely.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card bento-lg">
|
||||||
|
<div class="bento-icon">🔗</div>
|
||||||
|
<h3>DULT (unified, emerging)</h3>
|
||||||
|
<p>Service data <code>0xFCB2</code>; the near-owner bit (byte 14 LSB) marks separation. The cross-industry standard the whole ecosystem is converging on — VIGIL parses it today.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── HOW IT DECIDES ──────────────── -->
|
||||||
|
<section id="decides" class="section reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">how it decides</span>
|
||||||
|
<h2>Persistence, not proximity.</h2>
|
||||||
|
<p class="lead">A tracker is escalated only when it clears the
|
||||||
|
<strong>co-movement test</strong> — the same device, seen at many of
|
||||||
|
<em>your</em> distinct places, over a sustained window, while close enough
|
||||||
|
to actually be on you.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bento">
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">📍</div>
|
||||||
|
<h3>≥ N distinct places</h3>
|
||||||
|
<p>Geohash-7 cells; N = 2 / 3 / 4 by sensitivity. A tracker seen only where you dwell isn't following — it lives there.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">⏱</div>
|
||||||
|
<h3>≥ 3 sightings over ≥ T minutes</h3>
|
||||||
|
<p>Debounced to one per 15 min; T = 30 / 45 / 90 by sensitivity. Persistence across time, not a single blip.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">📶</div>
|
||||||
|
<h3>RSSI proximity gate</h3>
|
||||||
|
<p>It must have been genuinely close — on-body / in-bag — at least once. This is the piece AirGuard omits; it rejects "a Tile in a passing car."</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tiers" style="margin-top:2.4rem">
|
||||||
|
<div class="tier tier-green">
|
||||||
|
<div class="tier-name" style="font-size:1.15rem">OBSERVED</div>
|
||||||
|
<p>Seen, logged, geotagged — but hasn't cleared the co-movement test. No alarm.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tier tier-yellow">
|
||||||
|
<div class="tier-name" style="font-size:1.15rem">SUSPICIOUS</div>
|
||||||
|
<p>Co-moving across your places. VIGIL is watching it closely.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tier tier-red">
|
||||||
|
<div class="tier-name" style="font-size:1.15rem">ALERTING</div>
|
||||||
|
<p>Confirmed following you. Surfaced with the "Make it ring" and hot/cold finder tools.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-split" style="margin-top:2.8rem">
|
||||||
|
<div class="explain-annotations" style="grid-column:1 / -1">
|
||||||
|
<div class="annotation">
|
||||||
|
<span class="anno-num">✓</span>
|
||||||
|
<div>
|
||||||
|
<strong>Allowlist — "This is mine"</strong>
|
||||||
|
<p>Tap a tracker to approve it — your own AirTag, your partner's Tile — and it never alerts again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="annotation">
|
||||||
|
<span class="anno-num">✓</span>
|
||||||
|
<div>
|
||||||
|
<strong>Learned offline baseline</strong>
|
||||||
|
<p>VIGIL learns the places you dwell (home, work) as anchors; a tracker seen at an anchor across several days is auto-marked <strong>Known (home)</strong> — so household tags fall silent on their own, entirely on-device.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── SCREENSHOTS ──────────────── -->
|
||||||
|
<section id="screens" class="section reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">screenshots</span>
|
||||||
|
<h2>Spot it, then walk it down.</h2>
|
||||||
|
<p class="lead">No account, no map tiles, no cloud — it all runs on the
|
||||||
|
phone. See what's moving with you, wave off your own gear, and when
|
||||||
|
something's left over, home in on it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shots">
|
||||||
|
<figure class="shot">
|
||||||
|
<img class="phone" src="img/vigil-active.png" width="1096" height="2560"
|
||||||
|
alt="VIGIL active list: an ACTIVE (6) list of nearby Apple Find My and Google Find My Device trackers, each showing places seen, signal in dBm, time since last seen, and an It's-mine button." loading="lazy">
|
||||||
|
<figcaption><strong>The active list</strong>Every tracker seen moving with you — signal, places, and how long ago. Tap “It's mine” and your own gear drops out.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img class="phone" src="img/vigil-locate.png" width="1096" height="2560"
|
||||||
|
alt="VIGIL locate screen for an Apple Find My tracker: a large pulsing radar icon reading Searching… walk around, no signal yet, with a Make-it-ring button." loading="lazy">
|
||||||
|
<figcaption><strong>Locate mode</strong>Pick a suspect and walk — the ripple strengthens as you close in. No signal yet? Keep moving.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img class="phone" src="img/vigil-signal.png" width="1096" height="2560"
|
||||||
|
alt="VIGIL locate screen for a Google Find My Device tracker reading Close, minus 52 dBm, with a Make-it-ring button and a Ringing… listen for the tracker toast." loading="lazy">
|
||||||
|
<figcaption><strong>Getting warmer</strong>Live signal strength as you approach — −52 dBm is close. “Make it ring” forces a silent tracker to give itself up.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── CLONES ──────────────── -->
|
||||||
|
<section id="clones" class="section section-feature reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">the hard part</span>
|
||||||
|
<h2>Catching the clone.</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Every shipping detector — AirGuard, iOS, Android's built-in — keys on
|
||||||
|
<strong>device identity</strong>. A key-rotating clone (Positive
|
||||||
|
Security's <em>Find You</em>: ~2,000 Find My keys, a new one every 30 s)
|
||||||
|
looks like 2,000 one-off devices and evades them all — it tracked a
|
||||||
|
phone for five days with zero alerts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout" style="border-color:rgba(239,68,68,.32)">
|
||||||
|
<div class="callout-mark">🎯</div>
|
||||||
|
<div>
|
||||||
|
<h3>Detect the attack, not the device</h3>
|
||||||
|
<p>
|
||||||
|
A rotating clone is <em>one</em> physical radio holding an unbroken,
|
||||||
|
close-range, co-moving RF presence — even as its identity churns
|
||||||
|
thousands of times faster than any standards-compliant tracker is
|
||||||
|
allowed to. VIGIL's <code>PresenceEngine</code> pairs a CUSUM churn
|
||||||
|
trigger with an identity-agnostic presence-track confirmer and the
|
||||||
|
co-movement gate — an "identity-path × churn-path squeeze" that leaves
|
||||||
|
no safe rotation rate. First version implemented and unit-tested
|
||||||
|
against synthetic clone/ambient traces; field-tuning is what remains.
|
||||||
|
</p>
|
||||||
|
<a href="https://github.com/KaraZajac/VIGIL/blob/main/docs/detection-rotation-clone.md" class="audience-link" style="margin-top:.8rem;display:inline-block">Read the algorithm →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── FINDING + PRIVACY ──────────────── -->
|
||||||
|
<section class="section section-bento reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">on-device, private</span>
|
||||||
|
<h2>Fully offline. It only listens.</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bento">
|
||||||
|
<article class="bento-card bento-lg">
|
||||||
|
<div class="bento-icon">🔒</div>
|
||||||
|
<h3>No <code>INTERNET</code> permission at all</h3>
|
||||||
|
<p>
|
||||||
|
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 <strong>never leave the phone</strong>. Detection is
|
||||||
|
entirely passive — it listens only.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">🔔</div>
|
||||||
|
<h3>Make it ring</h3>
|
||||||
|
<p>Tap a suspected tracker to connect over GATT and play its own sound — the DULT-standard way for a victim to locate a hidden AirTag / Find My / Chipolo tag.</p>
|
||||||
|
</article>
|
||||||
|
<article class="bento-card">
|
||||||
|
<div class="bento-icon">🌡</div>
|
||||||
|
<h3>Hot / cold finder</h3>
|
||||||
|
<p>A passive proximity meter — warmer/colder from live signal. Works even on <strong>silent or modified tags that refuse to ring</strong>, which is exactly when you need it.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── DOWNLOAD ──────────────── -->
|
||||||
|
<section id="download" class="section section-timeline reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">get it</span>
|
||||||
|
<h2>Sideload in three steps.</h2>
|
||||||
|
</div>
|
||||||
|
<div class="timeline dl-steps" style="grid-template-columns:repeat(3,1fr) !important">
|
||||||
|
<div class="tl-col tl-shipped">
|
||||||
|
<div class="tl-tag">1 · download</div>
|
||||||
|
<ul><li>Grab the latest debug-signed APK from <a href="https://github.com/KaraZajac/VIGIL/releases/latest">Releases</a> (currently <strong>v0.1.7</strong>).</li></ul>
|
||||||
|
</div>
|
||||||
|
<div class="tl-col tl-active">
|
||||||
|
<div class="tl-tag">2 · install</div>
|
||||||
|
<ul><li>Sideload — allow "install unknown apps" for your browser or files app, then open the APK.</li></ul>
|
||||||
|
</div>
|
||||||
|
<div class="tl-col tl-next">
|
||||||
|
<div class="tl-tag">3 · grant + scan</div>
|
||||||
|
<ul><li>Grant nearby-devices + location + notifications, pick a sensitivity, and let it run in the background.</li></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="tl-foot">
|
||||||
|
Build from source, read the paper, or file issues:
|
||||||
|
<a href="https://github.com/KaraZajac/VIGIL">github.com/KaraZajac/VIGIL</a>
|
||||||
|
·
|
||||||
|
<a href="https://github.com/KaraZajac/VIGIL/blob/main/paper/vigil.md">the paper</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── FOOTER ──────────────── -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container footer-inner">
|
||||||
|
<div class="footer-col">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<img class="nav-logo" src="logo.svg" width="26" height="26" alt="" aria-hidden="true">
|
||||||
|
VIGIL
|
||||||
|
</div>
|
||||||
|
<p class="footer-tag">
|
||||||
|
Temporal counter-tracking for Android. Fully offline, listens only.
|
||||||
|
A sibling to OVERWATCH.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Project</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://github.com/KaraZajac/VIGIL">Source</a></li>
|
||||||
|
<li><a href="https://github.com/KaraZajac/VIGIL/releases">Releases</a></li>
|
||||||
|
<li><a href="https://github.com/KaraZajac/VIGIL/blob/main/paper/vigil.md">The paper</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Detects</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Apple Find My / AirTag</li>
|
||||||
|
<li>Google Find My · Tile</li>
|
||||||
|
<li>Samsung SmartTag · DULT</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>More</h4>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://overwatch.netslum.io">OVERWATCH</a></li>
|
||||||
|
<li><a href="https://netslum.io">netslum.io</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container footer-bottom">
|
||||||
|
<p>
|
||||||
|
VIGIL is a passive, on-device situational-awareness tool. It does not
|
||||||
|
transmit, probe, or interfere — except the user-initiated "Make it ring".
|
||||||
|
</p>
|
||||||
|
<p class="footer-meta">
|
||||||
|
v0.1.7 · <a href="https://github.com/KaraZajac/VIGIL">github.com/KaraZajac/VIGIL</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="app.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="VIGIL">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="36%" r="78%">
|
||||||
|
<stop offset="0" stop-color="#2a2a40"/>
|
||||||
|
<stop offset="1" stop-color="#11111b"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="100" height="100" rx="26" fill="url(#bg)" stroke="#313244"/>
|
||||||
|
<path d="M26 54 Q54 30 82 54 Q54 78 26 54 Z" fill="none" stroke="#89b4fa" stroke-width="4.5" stroke-linejoin="round"/>
|
||||||
|
<circle cx="54" cy="54" r="11" fill="none" stroke="#89b4fa" stroke-width="3.2"/>
|
||||||
|
<circle cx="54" cy="54" r="6.5" fill="none" stroke="#b4befe" stroke-width="2.8"/>
|
||||||
|
<circle cx="54" cy="54" r="3.2" fill="#a6e3a1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 740 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Space+Grotesk:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{width:1200px;height:630px;color:#cdd6f4;font-family:Inter,sans-serif;position:relative;overflow:hidden;
|
||||||
|
background:radial-gradient(130% 130% at 12% 6%,#26263c 0%,#1e1e2e 52%,#181825 100%)}
|
||||||
|
.wrap{padding:76px 82px;height:100%;display:flex;flex-direction:column;justify-content:center}
|
||||||
|
.brand{display:flex;align-items:center;gap:24px;margin-bottom:26px}
|
||||||
|
.brand img{width:92px;height:92px}
|
||||||
|
.name{font-family:"Space Grotesk",sans-serif;font-weight:700;font-size:94px;letter-spacing:.04em;color:#fff;line-height:1}
|
||||||
|
.tag{font-size:33px;line-height:1.36;color:#bac2de;font-weight:500;max-width:980px}
|
||||||
|
.tag b{color:#fff;font-weight:700}
|
||||||
|
.meta{position:absolute;bottom:82px;left:82px;font-family:"JetBrains Mono",monospace;font-size:19px;color:#9399b2}
|
||||||
|
.meta b{color:#89b4fa}
|
||||||
|
.url{position:absolute;bottom:50px;right:82px;font-family:"JetBrains Mono",monospace;font-size:20px;color:#6c7086}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="brand"><img src="logo.svg" alt=""><span class="name">VIGIL</span></div>
|
||||||
|
<div class="tag"><b>What's been following you?</b> A passive Android app that flags AirTags, Tile, SmartTags & Find My trackers travelling <b>with you over time</b> — even rotating-key clones.</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">Apple · Google · Samsung · Tile · <b>DULT</b> — fully offline, listens only</div>
|
||||||
|
<div class="url">vigil.netslum.io</div>
|
||||||
|
</body></html>
|
||||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 213 KiB |