Survivor side (4/4): truthful ring confirmation + 'Who owns it?'
Build APK / build (push) Waiting to run

- TrackerRinger now reads the DULT/Find My indication: reports 'Ringing confirmed' on the tag's response, or (after a 4s wait with no confirmation) 'sent, but the tracker didn't confirm - it only rings when separated from its owner'. AirTag still confirmed by its self-disconnect. No more over-claiming from a bare write-ACK.
- 'Who owns it?' on the tracker detail: guidance to tap the tag with NFC and let the phone open the owner's masked contact page (works via Android's built-in NFC dispatch; VIGIL stays offline).
Release 0.1.11 — completes the survivor-actionability work (safety + onboarding, history + export, trail, ring confirmation + owner info).

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 20:12:04 -04:00
parent 092552939d
commit 4b42b4431c
3 changed files with 57 additions and 22 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil" applicationId = "org.soulstone.vigil"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 11 versionCode = 12
versionName = "0.1.10" versionName = "0.1.11"
} }
// 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
@@ -12,6 +12,8 @@ import android.bluetooth.BluetoothProfile
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
@@ -98,7 +100,9 @@ object TrackerRinger {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
var gatt: BluetoothGatt? = null var gatt: BluetoothGatt? = null
var selected: Proto? = null var selected: Proto? = null
fun done(msg: String, g: BluetoothGatt) { val handler = Handler(Looper.getMainLooper())
fun finish(msg: String, g: BluetoothGatt) {
handler.removeCallbacksAndMessages(null)
if (cont.isActive) cont.resume(msg) if (cont.isActive) cont.resume(msg)
g.disconnect() g.disconnect()
} }
@@ -107,11 +111,14 @@ object TrackerRinger {
when (newState) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> g.discoverServices() BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
handler.removeCallbacksAndMessages(null)
// An AirTag self-disconnects (status 19) once it has started ringing. // An AirTag self-disconnects (status 19) once it has started ringing.
if (cont.isActive) { if (cont.isActive) {
cont.resume(
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT) if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
cont.resume("Ringing listen for the AirTag.") "Ringing - listen for the AirTag."
else cont.resume("Tracker disconnected before it could ring.") else "Tracker disconnected before it could ring."
)
} }
g.close() g.close()
} }
@@ -120,9 +127,9 @@ object TrackerRinger {
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null } val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
?: return done("This tracker type doesn't expose a remote-ring service.", g) ?: return finish("This tracker type doesn't expose a remote-ring service.", g)
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic) val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g) ?: return finish("Ring characteristic missing on this tracker.", g)
selected = proto selected = proto
if (proto.cccd) { if (proto.cccd) {
g.setCharacteristicNotification(ch, true) g.setCharacteristicNotification(ch, true)
@@ -131,32 +138,41 @@ object TrackerRinger {
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto) if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
} else { } else {
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g) if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
} }
} }
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) { override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
val proto = selected ?: return val proto = selected ?: return
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic) val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
?: return done("Ring characteristic missing on this tracker.", g) ?: return finish("Ring characteristic missing on this tracker.", g)
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g) if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
} }
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) { override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
// AirTag reports via self-disconnect; others confirm here. if (selected == AIRTAG) { finish("Ringing - listen for the AirTag.", g); return }
if (selected == AIRTAG) { if (status != BluetoothGatt.GATT_SUCCESS) { finish("The tracker refused the ring command.", g); return }
done("Ringing… listen for the AirTag.", g) // DULT/FindMy confirm via an indication. Wait briefly; if none arrives, the tag
} else { // likely isn't separated from its owner (so it won't actually ring).
done( handler.postDelayed({
if (status == BluetoothGatt.GATT_SUCCESS) finish("Ring command sent, but the tracker didn't confirm - it only rings when separated from its owner.", g)
"Ring command sent. A tag only chirps when it's separated from its owner — your own tag that's with you won't." }, 4000)
else "The tracker refused the ring command.", g
)
} }
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
finish("Ringing confirmed - listen for the tracker.", g)
}
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray) {
finish("Ringing confirmed - listen for the tracker.", g)
} }
} }
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE) gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } } cont.invokeOnCancellation {
handler.removeCallbacksAndMessages(null)
runCatching { gatt?.disconnect(); gatt?.close() }
}
} }
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Radar
import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -51,6 +52,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -479,6 +481,23 @@ private fun TrackerDetail(
Text("Ring it") Text("Ring it")
} }
} }
var showInfo by remember { mutableStateOf(false) }
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { showInfo = true }, modifier = Modifier.fillMaxWidth()) {
Text("Who owns it?")
}
if (showInfo) {
AlertDialog(
onDismissRequest = { showInfo = false },
confirmButton = { TextButton(onClick = { showInfo = false }) { Text("Got it") } },
title = { Text("Who owns this tracker?") },
text = {
Text(
"Tap the tracker against the top of your phone (NFC). If it's an AirTag or a DULT tag in lost mode, your phone opens a page with the owner's masked phone or email. VIGIL stays offline - the tag's NFC hands the link to your browser."
)
}
)
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
when (status) { when (status) {
TrackerStatus.SAFE_APPROVED -> TrackerStatus.SAFE_APPROVED ->