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
This commit is contained in:
Kara Zajac
2026-07-15 09:43:00 -04:00
parent f952a8e7ca
commit 0efc205220
6 changed files with 269 additions and 7 deletions
@@ -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,8 @@ class MainActivity : ComponentActivity() {
},
onDistrust = { id ->
lifecycleScope.launch { repo.clearBaseline(id) }
}
},
onRing = { tracker -> ringTracker(tracker) }
)
}
}
@@ -106,6 +110,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(
@@ -122,7 +122,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. */
@@ -114,7 +116,7 @@ interface PlaceDao {
@Database(
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
version = 2,
version = 3,
exportSchema = false
)
abstract class VigilDatabase : RoomDatabase() {
@@ -0,0 +1,127 @@
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.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 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
* DULT play-sound command, then disconnects.
*
* The cross-vendor DULT path covers Google Find My Device network tags and DULT
* partners (Chipolo/Pebblebee/eufy/Motorola). Apple AirTags use Apple's own sound
* service and will report "no remote-ring service" until that per-ecosystem path is
* added. The UUIDs/opcode below are from the DULT draft and are pending on-device
* verification.
*/
object TrackerRinger {
private const val TAG = "TrackerRinger"
private const val TIMEOUT_MS = 15_000L
// DULT accessory-protocol non-owner sound (IETF draft). VERIFY on-device.
private val DULT_SERVICE = UUID.fromString("15190001-12F4-C226-88ED-2AC5579F2A85")
private val DULT_NON_OWNER_CHAR = UUID.fromString("8E0C0001-1D68-FB92-BF61-48377421680E")
private val DULT_SOUND_START = byteArrayOf(0x00, 0x03) // opcode 0x0300 (verify endianness/format)
/** Returns a short user-facing status message. [ecosystem] is retained for the
* per-ecosystem paths (e.g. Apple) still to be added. */
suspend fun ring(context: Context, mac: String, ecosystem: String): String {
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
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
if (cont.isActive) cont.resume("Tracker disconnected before it could ring.")
g.close()
}
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val ch = g.getService(DULT_SERVICE)?.getCharacteristic(DULT_NON_OWNER_CHAR)
if (ch == null) {
if (cont.isActive) cont.resume("This tracker type doesn't expose a remote-ring service.")
g.disconnect()
return
}
if (!writeSound(g, ch) && cont.isActive) {
cont.resume("Couldn't send the ring command.")
g.disconnect()
}
}
override fun onCharacteristicWrite(
g: BluetoothGatt,
ch: BluetoothGattCharacteristic,
status: Int
) {
if (cont.isActive) cont.resume(
if (status == BluetoothGatt.GATT_SUCCESS) "Ringing… listen for the tracker."
else "The tracker refused the ring command."
)
g.disconnect()
}
}
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } }
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
private fun writeSound(g: BluetoothGatt, ch: BluetoothGattCharacteristic): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(ch, DULT_SOUND_START, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothGatt.GATT_SUCCESS
} else {
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
ch.value = DULT_SOUND_START
g.writeCharacteristic(ch)
}
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
}
@@ -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,14 @@
package org.soulstone.vigil.ui
import android.text.format.DateUtils
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
@@ -73,9 +81,11 @@ fun MainScreen(
onStartStop: () -> Unit,
onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit
onDistrust: (String) -> Unit,
onRing: (TrackerEntity) -> Unit
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
val now = System.currentTimeMillis()
val active = trackers
@@ -195,10 +205,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 +355,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 +388,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 +427,78 @@ 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) {
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"