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:
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user