diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9fcce4b..9cb5453 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -35,6 +35,10 @@
+
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt b/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt
index 359cb35..e41def0 100644
--- a/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt
+++ b/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt
@@ -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()
}
}
}
diff --git a/app/src/main/java/club/clubk/ktag/bletool/ScannerActivity.kt b/app/src/main/java/club/clubk/ktag/bletool/ScannerActivity.kt
new file mode 100644
index 0000000..41543c7
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/bletool/ScannerActivity.kt
@@ -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()
+
+ 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,
+ 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])
+ }
+}
\ No newline at end of file