Added a scanner.

This commit is contained in:
Joe Kearney 2025-02-14 19:23:25 -06:00
parent 4af85426c7
commit f3d9400dda
3 changed files with 488 additions and 2 deletions

View file

@ -35,6 +35,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ScannerActivity"
android:exported="false"
android:label="KTag Scanner" />
</application>
</manifest>

View file

@ -54,6 +54,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Icon
import android.content.Intent
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Scaffold
private const val TAG = "BLE Tool"
@ -206,6 +215,37 @@ val advertisementPresets = listOf(
)
)
@Composable
fun MainScreen() {
var selectedTab by remember { mutableStateOf(0) }
val context = LocalContext.current
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.AutoMirrored.Filled.Send, "Advertiser") },
label = { Text("Advertise") },
selected = selectedTab == 0,
onClick = { selectedTab = 0 }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Search, "Scanner") },
label = { Text("Scan") },
selected = selectedTab == 1,
onClick = {
context.startActivity(Intent(context, ScannerActivity::class.java))
}
)
}
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
BleAdvertiserScreen()
}
}
}
class MainActivity : ComponentActivity() {
private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
@ -238,7 +278,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
BleAdvertiserScreen()
MainScreen()
}
}
}
@ -277,7 +317,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
BleAdvertiserScreen()
MainScreen()
}
}
}

View file

@ -0,0 +1,442 @@
package club.clubk.ktag.bletool
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
private const val TAG = "BLE Scanner"
class ScannerActivity : ComponentActivity() {
private var isScanning by mutableStateOf(false)
private val scannedDevices = mutableStateMapOf<String, KTagPacket>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ScannerScreen(
isScanning = isScanning,
devices = scannedDevices,
onStartScan = { startScanning() },
onStopScan = { stopScanning() }
)
}
}
}
}
@SuppressLint("MissingPermission")
private fun startScanning() {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val scanner = bluetoothManager.adapter.bluetoothLeScanner ?: run {
Log.e(TAG, "BLE scanning not supported")
return
}
// Filter for KTag manufacturer data
val filter = ScanFilter.Builder()
.setManufacturerData(
0xFFFF, // Manufacturer ID
byteArrayOf(0x4B, 0x54, 0x61, 0x67) // "KTag"
)
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
scanner.startScan(listOf(filter), settings, scanCallback)
isScanning = true
}
private fun stopScanning() {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val scanner = bluetoothManager.adapter.bluetoothLeScanner
scanner?.stopScan(scanCallback)
isScanning = false
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
if (data.size >= 4 &&
data[0] == 0x4B.toByte() && // K
data[1] == 0x54.toByte() && // T
data[2] == 0x61.toByte() && // a
data[3] == 0x67.toByte() // g
) {
val packet = parseKTagPacket(data)
scannedDevices[result.device.address] = packet
}
}
}
override fun onScanFailed(errorCode: Int) {
val errorMessage = when (errorCode) {
ScanCallback.SCAN_FAILED_ALREADY_STARTED -> "Already started"
ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
ScanCallback.SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
else -> "Unknown error $errorCode"
}
Log.e(TAG, "BLE Scan failed: $errorMessage")
isScanning = false
}
}
}
@Composable
fun ScannerScreen(
isScanning: Boolean,
devices: Map<String, KTagPacket>,
onStartScan: () -> Unit,
onStopScan: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
TitleBox() // Reuse the same title component
Button(
onClick = if (isScanning) onStopScan else onStartScan,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Text(if (isScanning) "Stop Scanning" else "Start Scanning")
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(devices.entries.toList()) { (address, packet) ->
PacketCard(address = address, packet = packet)
}
}
}
}
@Composable
fun PacketCard(address: String, packet: KTagPacket) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(
modifier = Modifier.padding(8.dp)
) {
// Common header for all packets
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = packet.typeName,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
// Packet-specific content
when (packet) {
is InstigateGamePacket -> {
PacketRow("Game Length", "${packet.gameLength}ms")
PacketRow("Countdown", "${packet.countdownTime}ms")
}
is EventPacket -> {
PacketRow("Target", packet.targetAddress)
PacketRow("Event ID", packet.eventId.toString())
PacketRow("Event Data", packet.eventData.toString())
}
is TagPacket -> {
PacketRow("Team/Player", "${packet.teamId}/${packet.playerId}")
PacketRow("Damage", packet.damage.toString())
PacketRow("Protocol", packet.protocol.toString())
PacketRow("TX Power", "${packet.txPower}dBm")
PacketRow("Target", packet.targetAddress)
PacketRow("Color", String.format("#%08X", packet.color))
}
is ConsolePacket -> {
PacketRow("Message", packet.consoleString)
}
is StatusPacket -> {
PacketRow("Team/Player", "${packet.teamId}/${packet.playerId}")
PacketRow("Health", "${packet.health}/${packet.maxHealth}")
PacketRow("Protocol", packet.protocol.toString())
PacketRow("TX Power", "${packet.txPower}dBm")
PacketRow("Primary Color", String.format("#%08X", packet.primaryColor))
PacketRow("Secondary Color", String.format("#%08X", packet.secondaryColor))
PacketRow("State", packet.systemKState.toString())
}
is ConfigurationPacket -> {
PacketRow("Target", packet.targetAddress)
PacketRow("Subtype", packet.subtype.toString())
PacketRow("Key 1", packet.key1.toString())
PacketRow("Value 1", packet.value1.toString())
PacketRow("Key 2", packet.key2.toString())
PacketRow("Value 2", packet.value2.toString())
}
is HelloPacket -> {
PacketRow("Version", "${packet.majorVersion}.${packet.minorVersion}")
PacketRow("Device Type", packet.deviceType.toString())
PacketRow("Team ID", packet.teamId.toString())
PacketRow("Device Name", packet.deviceName)
}
}
// Show event number for all packets
Text(
text = "Event #${packet.eventNumber}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End)
)
}
}
}
@Composable
private fun PacketRow(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall
)
}
}
// Data classes for packet parsing
sealed class KTagPacket {
abstract val type: Int
abstract val typeName: String
abstract val eventNumber: Int
}
data class InstigateGamePacket(
override val type: Int,
override val typeName: String = "Instigate Game",
override val eventNumber: Int,
val gameLength: Int,
val countdownTime: Int
) : KTagPacket()
data class EventPacket(
override val type: Int,
override val typeName: String = "Event",
override val eventNumber: Int,
val targetAddress: String,
val eventId: Int,
val eventData: Int
) : KTagPacket()
data class TagPacket(
override val type: Int,
override val typeName: String = "Tag",
override val eventNumber: Int,
val txPower: Int,
val protocol: Int,
val teamId: Int,
val playerId: Int,
val damage: Int,
val color: Int,
val targetAddress: String
) : KTagPacket()
data class ConsolePacket(
override val type: Int,
override val typeName: String = "Console",
override val eventNumber: Int,
val consoleString: String
) : KTagPacket()
data class StatusPacket(
override val type: Int,
override val typeName: String = "Status",
override val eventNumber: Int,
val txPower: Int,
val protocol: Int,
val teamId: Int,
val playerId: Int,
val health: Int,
val maxHealth: Int,
val primaryColor: Int,
val secondaryColor: Int,
val systemKState: Int
) : KTagPacket()
data class ConfigurationPacket(
override val type: Int,
override val typeName: String = "Configuration",
override val eventNumber: Int,
val targetAddress: String,
val subtype: Int,
val key1: Int,
val value1: Int,
val key2: Int,
val value2: Int
) : KTagPacket()
data class HelloPacket(
override val type: Int,
override val typeName: String = "Hello",
override val eventNumber: Int,
val majorVersion: Int,
val minorVersion: Int,
val deviceType: Int,
val teamId: Int,
val deviceName: String
) : KTagPacket()
fun parseKTagPacket(data: ByteArray): KTagPacket {
val type = data[4].toInt()
val eventNumber = data[5].toInt()
return when (type) {
0x01 -> InstigateGamePacket(
type = type,
eventNumber = eventNumber,
gameLength = bytesToInt(data, 6, 4),
countdownTime = bytesToInt(data, 10, 4)
)
0x02 -> EventPacket(
type = type,
eventNumber = eventNumber,
targetAddress = bytesToMacAddress(data, 6),
eventId = bytesToInt(data, 12, 4),
eventData = bytesToInt(data, 16, 4)
)
0x03 -> TagPacket(
type = type,
eventNumber = eventNumber,
txPower = data[6].toInt(),
protocol = data[7].toInt(),
teamId = data[8].toInt(),
playerId = data[9].toInt(),
damage = bytesToInt(data, 10, 2),
color = bytesToInt(data, 12, 4),
targetAddress = bytesToMacAddress(data, 16)
)
0x04 -> ConsolePacket(
type = type,
eventNumber = eventNumber,
consoleString = data.slice(6..26).toByteArray().toString(Charsets.US_ASCII).trim(0.toChar())
)
0x05 -> StatusPacket(
type = type,
eventNumber = eventNumber,
txPower = data[6].toInt(),
protocol = data[7].toInt(),
teamId = data[8].toInt(),
playerId = data[8].toInt(),
health = bytesToInt(data, 10, 2),
maxHealth = bytesToInt(data, 12, 2),
primaryColor = bytesToInt(data, 14, 4),
secondaryColor = bytesToInt(data, 18, 4),
systemKState = data[22].toInt()
)
0x06 -> ConfigurationPacket(
type = type,
eventNumber = eventNumber,
targetAddress = bytesToMacAddress(data, 6),
subtype = data[12].toInt(),
key1 = bytesToInt(data, 13, 2),
value1 = bytesToInt(data, 15, 4),
key2 = bytesToInt(data, 19, 2),
value2 = bytesToInt(data, 21, 4)
)
0x07 -> HelloPacket(
type = type,
eventNumber = eventNumber,
majorVersion = data[6].toInt(),
minorVersion = data[7].toInt(),
deviceType = bytesToInt(data, 8, 2),
teamId = data[10].toInt(),
deviceName = data.slice(11..26).toByteArray().toString(Charsets.US_ASCII).trim(0.toChar())
)
else -> StatusPacket(
type = type,
typeName = "Unknown",
eventNumber = eventNumber,
txPower = 0,
protocol = 0,
teamId = 0,
playerId = 0,
health = 0,
maxHealth = 0,
primaryColor = 0,
secondaryColor = 0,
systemKState = 0
)
}
}
// Helper function to convert bytes to integers
private fun bytesToInt(data: ByteArray, offset: Int, length: Int): Int {
var result = 0
for (i in 0 until length) {
result = result or ((data[offset + i].toInt() and 0xFF) shl (8 * i))
}
return result
}
// Helper function to convert bytes to MAC address string
private fun bytesToMacAddress(data: ByteArray, offset: Int): String {
return (0..5).joinToString(":") {
String.format("%02X", data[offset + it])
}
}