VIGIL 0.1.0 — temporal counter-tracking (prototype)

Android app that detects personal item-trackers (AirTag, Tile, Samsung SmartTag, Google Find My Device / DULT) following the user over time. Sibling to OVERWATCH: OVERWATCH is spatial, VIGIL is temporal.

- BLE scan + per-ecosystem wire-format parsing (Apple 0x004C/0x12, FMDN 0xFEAA, Samsung 0xFD5A, Tile 0xFEED/FEEC, DULT 0xFCB2)
- Room temporal store (14-day sightings), co-movement evaluator with RSSI proximity gate
- Allowlist + learned offline baseline (auto-trust household tags)
- Foreground service, Compose UI (Catppuccin Mocha)
- Privacy: NO INTERNET permission — fully on-device
- Reuses OVERWATCH Gradle setup + committed debug keystore
- docs/detection-rotation-clone.md: design for the rotation-clone presence engine (#1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
Kara Zajac
2026-07-15 00:11:00 -04:00
commit 817a4ac1c9
40 changed files with 2585 additions and 0 deletions
@@ -0,0 +1,116 @@
package org.soulstone.vigil.scan
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import org.soulstone.vigil.model.TrackerObservation
/**
* BLE scanner for tracker advertisements. Unlike OVERWATCH's unfiltered scan, this
* uses hardware [ScanFilter]s for the tracker signatures — the controller wakes us
* only on a match, which is what makes screen-off/background scanning deliverable
* and battery-tolerable (research brief §5).
*
* Each match is parsed by [TrackerParser]; anything that parses is handed to
* [onObservation]. All correlation/temporal logic lives downstream.
*/
class BleTrackerScanner(
private val context: Context,
private val onObservation: (TrackerObservation) -> Unit
) {
companion object {
private const val TAG = "BleTrackerScanner"
}
private val adapter: BluetoothAdapter? by lazy {
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
}
private var leScanner: BluetoothLeScanner? = null
private var running = false
private val settings: ScanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.build()
private val filters: List<ScanFilter> = buildList {
// Apple Find My — match presence of Apple manufacturer data.
add(ScanFilter.Builder().setManufacturerData(TrackerSignatures.APPLE_COMPANY_ID, ByteArray(0)).build())
// FMDN / Samsung / Tile / DULT — match their service UUIDs.
for (uuid in TrackerSignatures.trackerServiceUuids) {
add(ScanFilter.Builder().setServiceUuid(uuid).build())
}
}
fun hasScanPermission(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) ==
PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running) return true
if (!hasScanPermission()) {
Log.w(TAG, "BLE scan permission missing")
return false
}
val a = adapter ?: return false
if (!a.isEnabled) {
Log.w(TAG, "Bluetooth disabled")
return false
}
leScanner = a.bluetoothLeScanner ?: return false
return try {
leScanner?.startScan(filters, settings, callback)
running = true
Log.i(TAG, "Tracker scan started (${filters.size} filters)")
true
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException starting scan", e)
false
}
}
@SuppressLint("MissingPermission")
fun stop() {
if (!running) return
try {
leScanner?.stopScan(callback)
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException stopping scan", e)
}
running = false
}
private val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
TrackerParser.parse(result)?.let(onObservation)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach { r -> TrackerParser.parse(r)?.let(onObservation) }
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed: $errorCode")
running = false
}
}
}
@@ -0,0 +1,112 @@
package org.soulstone.vigil.scan
import android.annotation.SuppressLint
import android.bluetooth.le.ScanResult
import org.soulstone.vigil.model.SeparatedState
import org.soulstone.vigil.model.TrackerEcosystem
import org.soulstone.vigil.model.TrackerObservation
import org.soulstone.vigil.scan.TrackerSignatures as Sig
/**
* Parses a raw [ScanResult] into a [TrackerObservation] if it matches one of the
* known tracker wire formats, else null. Pure/stateless.
*
* Identity strategy per ecosystem (see research brief §2 "what you can correlate"):
* - Apple: hash the advertised public-key bytes — stable across a ~24h epoch.
* - Google FMDN / Samsung / DULT: MAC is ~24h-static once separated; key on it.
* - Tile: MAC is permanently static; key on it directly.
*/
object TrackerParser {
@SuppressLint("MissingPermission")
fun parse(result: ScanResult): TrackerObservation? {
val record = result.scanRecord ?: return null
val mac = result.device?.address ?: return null
val rssi = result.rssi
// --- Apple Find My (manufacturer data, company 0x004C) ---
record.getManufacturerSpecificData(Sig.APPLE_COMPANY_ID)?.let { d ->
if (d.isNotEmpty() && (d[0].toInt() and 0xFF) == Sig.APPLE_TYPE_FINDMY) {
// Android strips the 2-byte company id, so d = [type(0x12),
// length(0x19), status, key(22), keyTopBits, hint]. The maintained
// bit lives in the status byte (index 2): set => near owner,
// cleared/absent => treat as separated.
val status = if (d.size > 2) d[2].toInt() and 0xFF else 0
val separated = if ((status and Sig.APPLE_STATUS_MAINTAINED_BIT) != 0)
SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
val keyBytes = if (d.size >= 25) d.copyOfRange(3, 25) else d
return TrackerObservation(
stableId = "apple:" + toHex(keyBytes),
ecosystem = TrackerEcosystem.APPLE_FIND_MY,
mac = mac, rssi = rssi, separated = separated,
label = "AirTag / Find My"
)
}
}
val sd = record.serviceData ?: emptyMap()
// --- Google Find My Device network (service data under FEAA) ---
sd[Sig.FMDN_UUID]?.let { d ->
val frame = if (d.isNotEmpty()) d[0].toInt() and 0xFF else -1
if (frame == Sig.FMDN_FRAME_NORMAL || frame == Sig.FMDN_FRAME_SEPARATED) {
val separated = if (frame == Sig.FMDN_FRAME_SEPARATED)
SeparatedState.SEPARATED else SeparatedState.NEAR_OWNER
return TrackerObservation(
stableId = "fmdn:$mac",
ecosystem = TrackerEcosystem.GOOGLE_FMDN,
mac = mac, rssi = rssi, separated = separated,
label = "Find My Device tag"
)
}
}
// --- Samsung Galaxy SmartTag (service data under FD5A) ---
sd[Sig.SAMSUNG_UUID]?.let { d ->
val state = if (d.isNotEmpty()) (d[0].toInt() shr 5) and 0x07 else -1
val separated = when (state) {
2, 3 -> SeparatedState.SEPARATED // lost / overmature-lost
4, 5, 6 -> SeparatedState.NEAR_OWNER // paired / connected
else -> SeparatedState.UNKNOWN
}
return TrackerObservation(
stableId = "samsung:$mac",
ecosystem = TrackerEcosystem.SAMSUNG_SMARTTAG,
mac = mac, rssi = rssi, separated = separated,
label = "Galaxy SmartTag"
)
}
// --- Tile (service data under FEED/FEEC/FE84). No separated flag; static MAC. ---
(sd[Sig.TILE_ACTIVE_UUID] ?: sd[Sig.TILE_PREACT_UUID] ?: sd[Sig.TILE_LEGACY_UUID])?.let {
return TrackerObservation(
stableId = "tile:$mac",
ecosystem = TrackerEcosystem.TILE,
mac = mac, rssi = rssi, separated = SeparatedState.UNKNOWN,
label = "Tile"
)
}
// --- DULT unified (service data under FCB2; near-owner bit = byte14 LSB) ---
sd[Sig.DULT_UUID]?.let { d ->
val nearOwner = d.size > 14 && (d[14].toInt() and 0x01) != 0
val separated = if (nearOwner) SeparatedState.NEAR_OWNER else SeparatedState.SEPARATED
return TrackerObservation(
stableId = "dult:$mac",
ecosystem = TrackerEcosystem.DULT,
mac = mac, rssi = rssi, separated = separated,
label = "DULT tracker"
)
}
return null
}
private fun toHex(bytes: ByteArray): String {
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) sb.append(HEX[(b.toInt() ushr 4) and 0xF]).append(HEX[b.toInt() and 0xF])
return sb.toString()
}
private val HEX = "0123456789abcdef".toCharArray()
}
@@ -0,0 +1,42 @@
package org.soulstone.vigil.scan
import android.os.ParcelUuid
import java.util.UUID
/**
* BLE wire-format constants for the tracker ecosystems, per the VIGIL research
* brief (2026-07-14). These are the scan targets. Offsets/values marked below
* are the ones flagged for empirical re-capture (SmartTag2, 2024+ Tile, current
* AirTag firmware) before they should be trusted absolutely.
*/
object TrackerSignatures {
// Apple Find My — manufacturer-specific data under company 0x004C.
const val APPLE_COMPANY_ID = 0x004C
const val APPLE_TYPE_FINDMY = 0x12 // Apple message type: offline finding
const val APPLE_STATUS_MAINTAINED_BIT = 0x04 // status byte bit2 set => near owner
// Samsung — company id (family-wide, not tag-unique; the FD5A service is the anchor).
const val SAMSUNG_COMPANY_ID = 0x0075
// Google Find My Device network frame types (first service-data byte under FEAA).
const val FMDN_FRAME_NORMAL = 0x40
const val FMDN_FRAME_SEPARATED = 0x41 // unwanted-tracking / separated mode
// 16-bit service UUIDs, expanded to the full Bluetooth base UUID.
val FMDN_UUID: ParcelUuid = uuid16(0xFEAA) // Google FMDN (Eddystone svc UUID)
val SAMSUNG_UUID: ParcelUuid = uuid16(0xFD5A) // Galaxy SmartTag offline finding
val TILE_ACTIVE_UUID: ParcelUuid = uuid16(0xFEED)
val TILE_PREACT_UUID: ParcelUuid = uuid16(0xFEEC)
val TILE_LEGACY_UUID: ParcelUuid = uuid16(0xFE84)
val DULT_UUID: ParcelUuid = uuid16(0xFCB2) // unified DULT (near-owner bit = byte14 LSB)
/** Service UUIDs used to build ScanFilters (enables screen-off scanning). */
val trackerServiceUuids: List<ParcelUuid> = listOf(
FMDN_UUID, SAMSUNG_UUID, TILE_ACTIVE_UUID, TILE_PREACT_UUID, TILE_LEGACY_UUID, DULT_UUID
)
/** Expand a 16-bit assigned number to the 128-bit `0000xxxx-0000-1000-8000-00805f9b34fb`. */
fun uuid16(v: Int): ParcelUuid =
ParcelUuid(UUID.fromString(String.format("%08x-0000-1000-8000-00805f9b34fb", 0x0000FFFF and v)))
}