7 Commits

Author SHA1 Message Date
Kara Zajac 41ab2f21a4 New app icon: Radar Eye (adaptive + themed monochrome)
Build APK / build (push) Has been cancelled
Replaces the inherited OVERWATCH shield with VIGIL's own mark: a watchful eye with a radar-target iris and a green centre contact, in Catppuccin Mocha. Foreground vector (safe-zone sized) + radial-gradient background layer + monochrome layer for Android 13+ themed icons. Release 0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 17:56:00 -04:00
Kara Zajac 333dd291cb Honest ring feedback (write-ACK != actually ringing)
Build APK / build (push) Has been cancelled
The DULT/FMDN path reported 'Ringing…' off the GATT write acknowledgement, not the tag's real response — so a tag that ACKs but declines to ring (e.g. because it's not separated from its owner) still showed success. Reworded to 'Ring command sent — a tag only chirps when separated from its owner.' Release 0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 13:22:32 -04:00
Kara Zajac bf15ead1ef Add 'Clear all' button to wipe the tracker list
Build APK / build (push) Has been cancelled
Trash action in the top bar (shown when the list is non-empty) clears all tracker rows + sighting history via repo.clearAll(). Devices re-populate as they're re-detected; learned baseline places are kept. Release 0.1.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 12:42:06 -04:00
Kara Zajac 6c460cbff7 Fix: back button on finder screen exits app instead of closing finder
Build APK / build (push) Has been cancelled
The hot/cold FinderScreen is a full-screen overlay with no BackHandler, so system-back fell through to the Activity and finished it. Added BackHandler(onBack = onClose) so back closes the finder and returns to the main list. Release 0.1.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 10:02:55 -04:00
Kara Zajac a2f293a486 Release 0.1.2: Find it (hot/cold) + Make it ring
Build APK / build (push) Has been cancelled
Adds the passive RSSI hot/cold finder and user-initiated GATT play-sound (AirTag/DULT/Find My; Samsung/Tile point to their apps). README notes ringing as the one active operation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:53:40 -04:00
Kara Zajac 4af3a2380a Fix Surface import; implement real per-ecosystem GATT ring protocols
- MainScreen: add missing Surface import (polished screen switched to Scaffold and dropped it; FinderScreen uses Surface) — was the compile break.
- TrackerRinger: real multi-protocol play-sound from AirGuard's verified source: AirTag native (single 0xAF byte, self-disconnects = success via status 19), DULT (0x0300, enable indications first), Find My legacy fd44 (enable notifications, [0x01,0x00,0x03]); tried in priority order by which service the tag exposes. Samsung SmartTag + Tile have no non-owner ring path -> tell the user to use SmartThings / the Tile app. API-version-aware characteristic + descriptor writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:47:59 -04:00
Kara Zajac 0efc205220 Add 'Find it' hot/cold RSSI finder + 'Make it ring' (GATT)
- FinderScreen: live smoothed RSSI as a growing, colour-shifting proximity meter (works on silent/modified tags that refuse to ring). Fed by ScanService.finderRssi (every advert, ahead of the DB throttle).
- TrackerRinger: user-initiated GATT play-sound — VIGIL's one active operation (detection stays passive). DULT cross-vendor path (Google FMDN + DULT partners) implemented; Apple/others report gracefully pending per-ecosystem UUIDs (GATT protocol research in progress). UUIDs/opcode pending on-device verification.
- Detail sheet: 'Find it' + 'Ring it' buttons; TrackerEntity.lastMac added (needed to GATT-connect); DB version 3.
- BLUETOOTH_CONNECT was already declared for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
2026-07-15 09:43:00 -04:00
12 changed files with 428 additions and 17 deletions
+16 -2
View File
@@ -26,8 +26,11 @@ that idea.
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
no telemetry. Every tracker, every sighting, and the entire learned baseline live
in an on-device SQLite database and never leave the phone. It listens only — it
never transmits, probes, or interferes with any device.
in an on-device SQLite database and never leave the phone. **Detection is entirely
passive — it listens only.** The one exception is the user-initiated **"Make it
ring"** action, which connects to a tracker you already suspect and asks it to play
a sound (the DULT-standard way for a victim to locate a hidden tag); nothing is
transmitted unless you tap it.
---
@@ -71,6 +74,17 @@ Two trust signals suppress false alarms:
auto-marked **Known (home)**. So the household tags that are always around you
fall silent on their own, entirely on-device.
## Finding a tracker
Tap any tracker for two ways to physically locate it:
- **Make it ring** — connects over GATT and plays the tracker's own sound. Works for
AirTags (native `0xAF` sound), Google Find My Device, and DULT tags (Chipolo,
Pebblebee, eufy, Motorola). Samsung SmartTag and Tile expose no non-owner ring, so
VIGIL points you to the SmartThings / Tile app instead.
- **Hot/cold finder** — a passive proximity meter that turns live signal strength into
a warmer/colder readout. It still works on **silent or modified tags that refuse to
ring**, which is exactly when you need it most.
## The hard part — catching clones (problem #1)
Every shipping detector (AirGuard, iOS, Android's built-in) keys on **device
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 2
versionName = "0.1.1"
versionCode = 7
versionName = "0.1.6"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -17,7 +18,9 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.soulstone.vigil.data.TrackerRepository
import org.soulstone.vigil.data.db.TrackerEntity
import org.soulstone.vigil.data.db.VigilDatabase
import org.soulstone.vigil.ring.TrackerRinger
import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.service.ScanService
import org.soulstone.vigil.ui.MainScreen
@@ -91,7 +94,9 @@ class MainActivity : ComponentActivity() {
},
onDistrust = { 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 }
.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")
private fun openAppSettings() {
startActivity(
@@ -44,6 +44,13 @@ class TrackerRepository(private val db: VigilDatabase) {
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
* 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,
peakRssi = maxOf(existing?.peakRssi ?: -127, obs.rssi),
distinctPlaces = assessment.distinctPlaces,
effectiveSightings = assessment.sightings
effectiveSightings = assessment.sightings,
lastMac = obs.mac
)
db.trackerDao().upsert(updated)
RecordResult(updated, newlyAlerting)
@@ -37,7 +37,9 @@ data class TrackerEntity(
val lastRssi: Int = 0,
val peakRssi: Int = -127,
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. */
@@ -89,6 +91,9 @@ interface TrackerDao {
// 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
@@ -101,6 +106,9 @@ interface SightingDao {
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
suspend fun prune(cutoff: Long)
@Query("DELETE FROM sightings")
suspend fun clearAll()
}
@Dao
@@ -114,7 +122,7 @@ interface PlaceDao {
@Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 2,
version = 3,
exportSchema = false
)
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)
val running: StateFlow<Boolean> = _running.asStateFlow()
// Live RSSI stream for the "find it" hot/cold screen. The UI sets a target
// stableId; every matching advert (not the throttled DB path) updates this.
@Volatile private var finderTarget: String? = null
private val _finderRssi = MutableStateFlow<Int?>(null)
val finderRssi: StateFlow<Int?> = _finderRssi.asStateFlow()
fun setFinderTarget(id: String?) {
finderTarget = id
_finderRssi.value = null
}
fun start(context: Context) {
val intent = Intent(context, ScanService::class.java).apply { action = ACTION_START }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -126,6 +136,9 @@ class ScanService : LifecycleService() {
private fun onObservation(obs: TrackerObservation) {
val fix = location.location.value
// Live RSSI for the "find it" screen — every advert, ahead of any throttle.
if (obs.stableId == finderTarget) _finderRssi.value = obs.rssi
// Rotation-clone presence engine (problem #1) — fed synchronously so its
// streaming state stays ordered. Only a CONFIRMED verdict raises a user
// alert; softer tiers stay silent until validated on real captures.
@@ -1,6 +1,15 @@
package org.soulstone.vigil.ui
import android.text.format.DateUtils
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material3.FilledTonalButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.lerp
import org.soulstone.vigil.service.ScanService
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -19,6 +28,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.GppMaybe
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Radar
@@ -33,9 +43,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -73,9 +85,12 @@ fun MainScreen(
onStartStop: () -> Unit,
onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
onDistrust: (String) -> Unit,
onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
val now = System.currentTimeMillis()
val active = trackers
@@ -97,6 +112,15 @@ fun MainScreen(
}
},
actions = {
if (trackers.isNotEmpty()) {
IconButton(onClick = onClearAll) {
Icon(
Icons.Filled.DeleteSweep,
contentDescription = "Clear all trackers",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
Icon(
Icons.Filled.Lock, null,
@@ -195,10 +219,20 @@ fun MainScreen(
TrackerDetail(
t,
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
@@ -335,7 +369,9 @@ private fun TrackerCard(
private fun TrackerDetail(
t: TrackerEntity,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
onDistrust: (String) -> Unit,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
@@ -366,6 +402,19 @@ private fun TrackerDetail(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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) {
TrackerStatus.SAFE_APPROVED ->
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 ----------------------------------------------------------
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"?>
<!-- VIGIL "Radar Eye" — a watchful eye whose iris is a radar target. Content sits
within the adaptive-icon safe zone (centre ~66dp of the 108dp canvas). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- eye outline -->
<path
android:fillColor="#1FAA59"
android:pathData="M54,30 m-18,0 a18,18 0 1,0 36,0 a18,18 0 1,0 -36,0" />
android:pathData="M26,54 Q54,30 82,54 Q54,78 26,54 Z"
android:strokeColor="#89B4FA"
android:strokeWidth="4.5"
android:strokeLineJoin="round"
android:fillColor="#00000000" />
<!-- radar iris — outer ring -->
<path
android:fillColor="#F4F6FA"
android:pathData="M54,30 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
android:pathData="M54,54 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0"
android:strokeColor="#89B4FA"
android:strokeWidth="3.2"
android:fillColor="#00000000" />
<!-- radar iris — inner ring -->
<path
android:pathData="M54,54 m-6.5,0 a6.5,6.5 0 1,0 13,0 a6.5,6.5 0 1,0 -13,0"
android:strokeColor="#B4BEFE"
android:strokeWidth="2.8"
android:fillColor="#00000000" />
<!-- centre contact (the detected device) -->
<path
android:pathData="M54,54 m-3.2,0 a3.2,3.2 0 1,0 6.4,0 a3.2,3.2 0 1,0 -6.4,0"
android:fillColor="#A6E3A1" />
</vector>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_dark" />
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>