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
This commit is contained in:
Kara Zajac
2026-07-15 09:47:59 -04:00
parent 0efc205220
commit 4af3a2380a
2 changed files with 99 additions and 35 deletions
@@ -6,6 +6,7 @@ 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
@@ -16,33 +17,62 @@ 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
* DULT play-sound command, then disconnects.
* play-sound command, then disconnects. No pairing/bonding is used (non-owner sound
* needs none), matching AirGuard.
*
* 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.
* 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"
// 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)
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")
/** 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 {
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
@@ -67,40 +97,61 @@ object TrackerRinger {
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 -> {
if (cont.isActive) cont.resume("Tracker disconnected before it could ring.")
// 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 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()
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 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()
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) "Ringing… listen for the tracker."
else "The tracker refused the ring command.", g
)
}
}
}
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
@@ -109,19 +160,31 @@ object TrackerRinger {
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission")
private fun writeSound(g: BluetoothGatt, ch: BluetoothGattCharacteristic): Boolean =
private fun writeStart(g: BluetoothGatt, ch: BluetoothGattCharacteristic, proto: Proto): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(ch, DULT_SOUND_START, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
g.writeCharacteristic(ch, proto.start, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothGatt.GATT_SUCCESS
} else {
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
ch.value = DULT_SOUND_START
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)
}
@@ -44,6 +44,7 @@ import androidx.compose.material3.Icon
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