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