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