From 0efc205220cd200429f9894093729581c95dfb5b Mon Sep 17 00:00:00 2001 From: Kara Zajac Date: Wed, 15 Jul 2026 09:43:00 -0400 Subject: [PATCH] Add 'Find it' hot/cold RSSI finder + 'Make it ring' (GATT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5 --- .../org/soulstone/vigil/MainActivity.kt | 14 +- .../soulstone/vigil/data/TrackerRepository.kt | 3 +- .../soulstone/vigil/data/db/VigilDatabase.kt | 6 +- .../org/soulstone/vigil/ring/TrackerRinger.kt | 127 ++++++++++++++++++ .../soulstone/vigil/service/ScanService.kt | 13 ++ .../org/soulstone/vigil/ui/MainScreen.kt | 113 +++++++++++++++- 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt diff --git a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt index 39a475c..def4234 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt @@ -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( diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt index fbd951e..dde75cb 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/TrackerRepository.kt @@ -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) diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt index f01c2fe..56893e1 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/db/VigilDatabase.kt @@ -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() { diff --git a/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt b/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt new file mode 100644 index 0000000..1d54972 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt @@ -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 +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt index 6d7aad6..1bfedcb 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/service/ScanService.kt @@ -61,6 +61,16 @@ class ScanService : LifecycleService() { private val _running = MutableStateFlow(false) val running: StateFlow = _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(null) + val finderRssi: StateFlow = _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. diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt index 48aca2e..b7ce67c 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -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(null) } + var finding by remember { mutableStateOf(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"