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:
@@ -6,6 +6,7 @@ import android.bluetooth.BluetoothDevice
|
|||||||
import android.bluetooth.BluetoothGatt
|
import android.bluetooth.BluetoothGatt
|
||||||
import android.bluetooth.BluetoothGattCallback
|
import android.bluetooth.BluetoothGattCallback
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import android.bluetooth.BluetoothGattDescriptor
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.bluetooth.BluetoothProfile
|
import android.bluetooth.BluetoothProfile
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -16,33 +17,62 @@ import androidx.core.content.ContextCompat
|
|||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-initiated "make it ring" over BLE GATT — VIGIL's ONE active operation
|
* 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
|
* (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
|
* Protocols (from AirGuard's AppleFindMy.kt + the IETF DULT draft, tried in order by
|
||||||
* partners (Chipolo/Pebblebee/eufy/Motorola). Apple AirTags use Apple's own sound
|
* whichever service the tag exposes):
|
||||||
* service and will report "no remote-ring service" until that per-ecosystem path is
|
* - AirTag native: write a single 0xAF byte, no CCCD; the tag rings and self-disconnects.
|
||||||
* added. The UUIDs/opcode below are from the DULT draft and are pending on-device
|
* - DULT (Google FMDN + Chipolo/Pebblebee/eufy/Motorola): enable indications, write 0x0300.
|
||||||
* verification.
|
* - 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 {
|
object TrackerRinger {
|
||||||
|
|
||||||
private const val TAG = "TrackerRinger"
|
private const val TAG = "TrackerRinger"
|
||||||
private const val TIMEOUT_MS = 15_000L
|
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 data class Proto(
|
||||||
private val DULT_SERVICE = UUID.fromString("15190001-12F4-C226-88ED-2AC5579F2A85")
|
val name: String,
|
||||||
private val DULT_NON_OWNER_CHAR = UUID.fromString("8E0C0001-1D68-FB92-BF61-48377421680E")
|
val service: UUID,
|
||||||
private val DULT_SOUND_START = byteArrayOf(0x00, 0x03) // opcode 0x0300 (verify endianness/format)
|
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 {
|
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 (mac.isBlank()) return "No recent address for this tracker — keep watching and try again."
|
||||||
if (!hasConnectPermission(context)) return "Grant Bluetooth (Connect) to ring trackers."
|
if (!hasConnectPermission(context)) return "Grant Bluetooth (Connect) to ring trackers."
|
||||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
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 =
|
private suspend fun attemptRing(context: Context, device: BluetoothDevice): String =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
var gatt: BluetoothGatt? = null
|
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() {
|
val callback = object : BluetoothGattCallback() {
|
||||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||||
when (newState) {
|
when (newState) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
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()
|
g.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
val ch = g.getService(DULT_SERVICE)?.getCharacteristic(DULT_NON_OWNER_CHAR)
|
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
|
||||||
if (ch == null) {
|
?: return done("This tracker type doesn't expose a remote-ring service.", g)
|
||||||
if (cont.isActive) cont.resume("This tracker type doesn't expose a remote-ring service.")
|
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||||
g.disconnect()
|
?: return done("Ring characteristic missing on this tracker.", g)
|
||||||
return
|
selected = proto
|
||||||
}
|
if (proto.cccd) {
|
||||||
if (!writeSound(g, ch) && cont.isActive) {
|
g.setCharacteristicNotification(ch, true)
|
||||||
cont.resume("Couldn't send the ring command.")
|
val desc = ch.getDescriptor(CCCD)
|
||||||
g.disconnect()
|
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(
|
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
|
||||||
g: BluetoothGatt,
|
val proto = selected ?: return
|
||||||
ch: BluetoothGattCharacteristic,
|
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||||
status: Int
|
?: return done("Ring characteristic missing on this tracker.", g)
|
||||||
) {
|
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
|
||||||
if (cont.isActive) cont.resume(
|
}
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS) "Ringing… listen for the tracker."
|
|
||||||
else "The tracker refused the ring command."
|
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||||
)
|
// AirTag reports via self-disconnect; others confirm here.
|
||||||
g.disconnect()
|
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)
|
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||||
@@ -109,19 +160,31 @@ object TrackerRinger {
|
|||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@SuppressLint("MissingPermission")
|
@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) {
|
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
|
BluetoothGatt.GATT_SUCCESS
|
||||||
} else {
|
} else {
|
||||||
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
ch.value = DULT_SOUND_START
|
ch.value = proto.start
|
||||||
g.writeCharacteristic(ch)
|
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 =
|
private fun hasConnectPermission(context: Context): Boolean =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
} else true
|
} 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.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
|||||||
Reference in New Issue
Block a user