Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ab2f21a4 | |||
| 333dd291cb | |||
| bf15ead1ef | |||
| 6c460cbff7 | |||
| a2f293a486 | |||
| 4af3a2380a | |||
| 0efc205220 |
@@ -26,8 +26,11 @@ that idea.
|
|||||||
|
|
||||||
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 +74,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 = 2
|
versionCode = 7
|
||||||
versionName = "0.1.1"
|
versionName = "0.1.6"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -17,7 +18,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.MainScreen
|
import org.soulstone.vigil.ui.MainScreen
|
||||||
@@ -91,7 +94,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
onDistrust = { id ->
|
onDistrust = { id ->
|
||||||
lifecycleScope.launch { repo.clearBaseline(id) }
|
lifecycleScope.launch { repo.clearBaseline(id) }
|
||||||
}
|
},
|
||||||
|
onRing = { tracker -> ringTracker(tracker) },
|
||||||
|
onClearAll = { lifecycleScope.launch { repo.clearAll() } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +111,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
.filter { it != Manifest.permission.POST_NOTIFICATIONS }
|
.filter { it != Manifest.permission.POST_NOTIFICATIONS }
|
||||||
.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
|
.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
|
private fun ringTracker(tracker: TrackerEntity) {
|
||||||
|
Toast.makeText(this, "Trying to ring ${tracker.label}…", Toast.LENGTH_SHORT).show()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val msg = TrackerRinger.ring(this@MainActivity, tracker.lastMac, tracker.ecosystem)
|
||||||
|
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
private fun openAppSettings() {
|
private fun openAppSettings() {
|
||||||
startActivity(
|
startActivity(
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
db.trackerDao().pruneStale(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()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ingest one sighting. Persists it, updates the baseline, and re-evaluates the
|
* Ingest one sighting. Persists it, updates the baseline, and re-evaluates the
|
||||||
* tracker's risk. Returns the updated row and whether this crossed into ALERTING.
|
* tracker's risk. Returns the updated row and whether this crossed into ALERTING.
|
||||||
@@ -122,7 +129,8 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
lastRssi = obs.rssi,
|
lastRssi = obs.rssi,
|
||||||
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
|
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
|
||||||
distinctPlaces = assessment.distinctPlaces,
|
distinctPlaces = assessment.distinctPlaces,
|
||||||
effectiveSightings = assessment.sightings
|
effectiveSightings = assessment.sightings,
|
||||||
|
lastMac = obs.mac
|
||||||
)
|
)
|
||||||
db.trackerDao().upsert(updated)
|
db.trackerDao().upsert(updated)
|
||||||
RecordResult(updated, newlyAlerting)
|
RecordResult(updated, newlyAlerting)
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ data class TrackerEntity(
|
|||||||
val lastRssi: Int = 0,
|
val lastRssi: Int = 0,
|
||||||
val peakRssi: Int = -127,
|
val peakRssi: Int = -127,
|
||||||
val distinctPlaces: Int = 0,
|
val distinctPlaces: Int = 0,
|
||||||
val effectiveSightings: 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. */
|
||||||
@@ -89,6 +91,9 @@ interface TrackerDao {
|
|||||||
// stale, non-approved rows so the table and the UI list don't grow without bound.
|
// 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")
|
@Query("DELETE FROM trackers WHERE lastSeen < :cutoff AND approved = 0")
|
||||||
suspend fun pruneStale(cutoff: Long)
|
suspend fun pruneStale(cutoff: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM trackers")
|
||||||
|
suspend fun clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -101,6 +106,9 @@ interface SightingDao {
|
|||||||
|
|
||||||
@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
|
||||||
@@ -114,7 +122,7 @@ interface PlaceDao {
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class VigilDatabase : RoomDatabase() {
|
abstract class VigilDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package org.soulstone.vigil.ring
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothGatt
|
||||||
|
import android.bluetooth.BluetoothGattCallback
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import android.bluetooth.BluetoothGattDescriptor
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-initiated "make it ring" over BLE GATT — VIGIL's ONE active operation
|
||||||
|
* (detection stays fully passive). Connects to a separated tracker and writes the
|
||||||
|
* play-sound command, then disconnects. No pairing/bonding is used (non-owner sound
|
||||||
|
* needs none), matching AirGuard.
|
||||||
|
*
|
||||||
|
* Protocols (from AirGuard's AppleFindMy.kt + the IETF DULT draft, tried in order by
|
||||||
|
* whichever service the tag exposes):
|
||||||
|
* - AirTag native: write a single 0xAF byte, no CCCD; the tag rings and self-disconnects.
|
||||||
|
* - DULT (Google FMDN + Chipolo/Pebblebee/eufy/Motorola): enable indications, write 0x0300.
|
||||||
|
* - Find My legacy (fd44): enable notifications, write [0x01,0x00,0x03].
|
||||||
|
* Samsung SmartTag and Tile expose NO non-owner ring — the app tells the user to use
|
||||||
|
* the vendor app instead.
|
||||||
|
*/
|
||||||
|
object TrackerRinger {
|
||||||
|
|
||||||
|
private const val TAG = "TrackerRinger"
|
||||||
|
private const val TIMEOUT_MS = 15_000L
|
||||||
|
private const val STATUS_PEER_DISCONNECT = 19 // GATT_CONN_TERMINATE_PEER_USER — AirTag "done ringing"
|
||||||
|
|
||||||
|
private data class Proto(
|
||||||
|
val name: String,
|
||||||
|
val service: UUID,
|
||||||
|
val characteristic: UUID,
|
||||||
|
val start: ByteArray,
|
||||||
|
val cccd: Boolean,
|
||||||
|
val indicate: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private val AIRTAG = Proto(
|
||||||
|
"AirTag", uuid("7DFC9000-7D1C-4951-86AA-8D9728F8D66C"),
|
||||||
|
uuid("7DFC9001-7D1C-4951-86AA-8D9728F8D66C"), byteArrayOf(0xAF.toByte()), cccd = false, indicate = false
|
||||||
|
)
|
||||||
|
private val DULT = Proto(
|
||||||
|
"DULT", uuid("15190001-12F4-C226-88ED-2AC5579F2A85"),
|
||||||
|
uuid("8E0C0001-1D68-FB92-BF61-48377421680E"), byteArrayOf(0x00, 0x03), cccd = true, indicate = true
|
||||||
|
)
|
||||||
|
private val FINDMY = Proto(
|
||||||
|
"FindMy", uuid("0000FD44-0000-1000-8000-00805F9B34FB"),
|
||||||
|
uuid("4F860003-943B-49EF-BED4-2F730304427A"), byteArrayOf(0x01, 0x00, 0x03), cccd = true, indicate = false
|
||||||
|
)
|
||||||
|
private val PRIORITY = listOf(AIRTAG, DULT, FINDMY)
|
||||||
|
private val CCCD = uuid("00002902-0000-1000-8000-00805F9B34FB")
|
||||||
|
|
||||||
|
suspend fun ring(context: Context, mac: String, ecosystem: String): String {
|
||||||
|
when (runCatching { TrackerEcosystem.valueOf(ecosystem) }.getOrNull()) {
|
||||||
|
TrackerEcosystem.SAMSUNG_SMARTTAG ->
|
||||||
|
return "Samsung SmartTags can only be rung from the SmartThings app (owner-only)."
|
||||||
|
TrackerEcosystem.TILE ->
|
||||||
|
return "Tiles can only be rung from the Tile app (owner-only)."
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
if (mac.isBlank()) return "No recent address for this tracker — keep watching and try again."
|
||||||
|
if (!hasConnectPermission(context)) return "Grant Bluetooth (Connect) to ring trackers."
|
||||||
|
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||||
|
?: return "Bluetooth unavailable."
|
||||||
|
if (!adapter.isEnabled) return "Turn on Bluetooth to ring trackers."
|
||||||
|
val device = try {
|
||||||
|
adapter.getRemoteDevice(mac)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
return "Invalid device address."
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
withTimeout(TIMEOUT_MS) { attemptRing(context, device) }
|
||||||
|
} catch (e: TimeoutCancellationException) {
|
||||||
|
"Couldn't reach the tracker — it may be out of range, or a silent/modified tag that ignores ring commands."
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ring failed", e)
|
||||||
|
"Ring failed: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private suspend fun attemptRing(context: Context, device: BluetoothDevice): String =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
var gatt: BluetoothGatt? = null
|
||||||
|
var selected: Proto? = null
|
||||||
|
fun done(msg: String, g: BluetoothGatt) {
|
||||||
|
if (cont.isActive) cont.resume(msg)
|
||||||
|
g.disconnect()
|
||||||
|
}
|
||||||
|
val callback = object : BluetoothGattCallback() {
|
||||||
|
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||||
|
when (newState) {
|
||||||
|
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
||||||
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
|
// An AirTag self-disconnects (status 19) once it has started ringing.
|
||||||
|
if (cont.isActive) {
|
||||||
|
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
|
||||||
|
cont.resume("Ringing… listen for the AirTag.")
|
||||||
|
else cont.resume("Tracker disconnected before it could ring.")
|
||||||
|
}
|
||||||
|
g.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
|
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
|
||||||
|
?: return done("This tracker type doesn't expose a remote-ring service.", g)
|
||||||
|
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||||
|
?: return done("Ring characteristic missing on this tracker.", g)
|
||||||
|
selected = proto
|
||||||
|
if (proto.cccd) {
|
||||||
|
g.setCharacteristicNotification(ch, true)
|
||||||
|
val desc = ch.getDescriptor(CCCD)
|
||||||
|
val v = if (proto.indicate) BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||||
|
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
|
||||||
|
} else {
|
||||||
|
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
|
||||||
|
val proto = selected ?: return
|
||||||
|
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||||
|
?: return done("Ring characteristic missing on this tracker.", g)
|
||||||
|
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||||
|
// AirTag reports via self-disconnect; others confirm here.
|
||||||
|
if (selected == AIRTAG) {
|
||||||
|
done("Ringing… listen for the AirTag.", g)
|
||||||
|
} else {
|
||||||
|
done(
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS)
|
||||||
|
"Ring command sent. A tag only chirps when it's separated from its owner — your own tag that's with you won't."
|
||||||
|
else "The tracker refused the ring command.", g
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun writeStart(g: BluetoothGatt, ch: BluetoothGattCharacteristic, proto: Proto): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
g.writeCharacteristic(ch, proto.start, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
|
||||||
|
BluetoothGatt.GATT_SUCCESS
|
||||||
|
} else {
|
||||||
|
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
ch.value = proto.start
|
||||||
|
g.writeCharacteristic(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun writeDescriptor(g: BluetoothGatt, desc: BluetoothGattDescriptor, value: ByteArray): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
g.writeDescriptor(desc, value) == BluetoothGatt.GATT_SUCCESS
|
||||||
|
} else {
|
||||||
|
desc.value = value
|
||||||
|
g.writeDescriptor(desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasConnectPermission(context: Context): Boolean =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
} else true
|
||||||
|
|
||||||
|
private fun uuid(s: String): UUID = UUID.fromString(s)
|
||||||
|
}
|
||||||
@@ -61,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) {
|
||||||
@@ -126,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.
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
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 org.soulstone.vigil.service.ScanService
|
||||||
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,6 +28,7 @@ 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.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Radar
|
import androidx.compose.material.icons.filled.Radar
|
||||||
@@ -33,9 +43,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.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.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
|
||||||
@@ -73,9 +85,12 @@ fun MainScreen(
|
|||||||
onStartStop: () -> Unit,
|
onStartStop: () -> Unit,
|
||||||
onSetSensitivity: (Sensitivity) -> Unit,
|
onSetSensitivity: (Sensitivity) -> Unit,
|
||||||
onApprove: (String, Boolean) -> Unit,
|
onApprove: (String, Boolean) -> Unit,
|
||||||
onDistrust: (String) -> Unit
|
onDistrust: (String) -> Unit,
|
||||||
|
onRing: (TrackerEntity) -> Unit,
|
||||||
|
onClearAll: () -> Unit
|
||||||
) {
|
) {
|
||||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
|
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val active = trackers
|
val active = trackers
|
||||||
@@ -97,6 +112,15 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (trackers.isNotEmpty()) {
|
||||||
|
IconButton(onClick = onClearAll) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.DeleteSweep,
|
||||||
|
contentDescription = "Clear all trackers",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Lock, null,
|
Icons.Filled.Lock, null,
|
||||||
@@ -195,10 +219,20 @@ fun MainScreen(
|
|||||||
TrackerDetail(
|
TrackerDetail(
|
||||||
t,
|
t,
|
||||||
onApprove = { id, a -> onApprove(id, a); detail = null },
|
onApprove = { id, a -> onApprove(id, a); detail = null },
|
||||||
onDistrust = { id -> onDistrust(id); detail = null }
|
onDistrust = { id -> onDistrust(id); detail = null },
|
||||||
|
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
||||||
|
onRing = onRing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finding?.let { dev ->
|
||||||
|
FinderScreen(
|
||||||
|
dev,
|
||||||
|
onRing = onRing,
|
||||||
|
onClose = { ScanService.setFinderTarget(null); finding = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -335,7 +369,9 @@ private fun TrackerCard(
|
|||||||
private fun TrackerDetail(
|
private fun TrackerDetail(
|
||||||
t: TrackerEntity,
|
t: TrackerEntity,
|
||||||
onApprove: (String, Boolean) -> Unit,
|
onApprove: (String, Boolean) -> Unit,
|
||||||
onDistrust: (String) -> Unit
|
onDistrust: (String) -> Unit,
|
||||||
|
onFind: (TrackerEntity) -> Unit,
|
||||||
|
onRing: (TrackerEntity) -> Unit
|
||||||
) {
|
) {
|
||||||
val status = statusOf(t)
|
val status = statusOf(t)
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
@@ -366,6 +402,19 @@ private fun TrackerDetail(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Sensors, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Find it")
|
||||||
|
}
|
||||||
|
FilledTonalButton(onClick = { onRing(t) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.NotificationsActive, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Ring it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
when (status) {
|
when (status) {
|
||||||
TrackerStatus.SAFE_APPROVED ->
|
TrackerStatus.SAFE_APPROVED ->
|
||||||
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -392,6 +441,80 @@ private fun DetailRow(label: String, value: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Passive hot/cold finder — smoothed live RSSI as a growing, colour-shifting
|
||||||
|
* proximity meter. Works even on silent/modified tags that refuse to ring. */
|
||||||
|
@Composable
|
||||||
|
private fun FinderScreen(t: TrackerEntity, onRing: (TrackerEntity) -> Unit, onClose: () -> Unit) {
|
||||||
|
// Intercept system back so it closes the finder instead of exiting the app.
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
val rssi by ScanService.finderRssi.collectAsState()
|
||||||
|
val smoothed = remember { mutableStateOf(-100f) }
|
||||||
|
LaunchedEffect(rssi) { rssi?.let { smoothed.value = 0.35f * it + 0.65f * smoothed.value } }
|
||||||
|
val hasSignal = rssi != null
|
||||||
|
val p = ((smoothed.value + 100f) / 60f).coerceIn(0f, 1f) // -100 dBm..-40 dBm -> 0..1
|
||||||
|
val label = when {
|
||||||
|
!hasSignal -> "Searching… walk around"
|
||||||
|
p > 0.8f -> "Right here"
|
||||||
|
p > 0.6f -> "Very close"
|
||||||
|
p > 0.4f -> "Close"
|
||||||
|
p > 0.2f -> "Getting warmer"
|
||||||
|
else -> "Far"
|
||||||
|
}
|
||||||
|
val color = lerp(VigilRed, VigilGreen, p)
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
ecosystemDisplay(t.ecosystem),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Box(
|
||||||
|
Modifier.size((120 + p * 160).dp).clip(CircleShape).background(
|
||||||
|
if (hasSignal) color.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Sensors, null,
|
||||||
|
tint = if (hasSignal) color else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (hasSignal) color else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (hasSignal) "$rssi dBm" else "no signal yet",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
Button(onClick = { onRing(t) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||||
|
Icon(Icons.Filled.NotificationsActive, null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("Make it ring")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
TextButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Done") }
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"If it won't ring, it may be a silent or modified tracker — use the signal above to home in on it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- pure helpers ----------------------------------------------------------
|
// ---- pure helpers ----------------------------------------------------------
|
||||||
|
|
||||||
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
|
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user