Added a scanner.
This commit is contained in:
parent
4af85426c7
commit
f3d9400dda
3 changed files with 488 additions and 2 deletions
|
@ -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>
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
442
app/src/main/java/club/clubk/ktag/bletool/ScannerActivity.kt
Normal file
442
app/src/main/java/club/clubk/ktag/bletool/ScannerActivity.kt
Normal 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])
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue