From 4af3a2380a37213082bedb5ff579a01ac8ad6746 Mon Sep 17 00:00:00 2001 From: Kara Zajac Date: Wed, 15 Jul 2026 09:47:59 -0400 Subject: [PATCH] Fix Surface import; implement real per-ecosystem GATT ring protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5 --- .../org/soulstone/vigil/ring/TrackerRinger.kt | 133 +++++++++++++----- .../org/soulstone/vigil/ui/MainScreen.kt | 1 + 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt b/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt index 1d54972..cc1395b 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ring/TrackerRinger.kt @@ -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) } 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 b7ce67c..6189ed1 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -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