Initial public release.

This commit is contained in:
Joe Kearney 2026-03-01 17:03:10 -06:00
parent ed31acd60f
commit 58d87b11b7
249 changed files with 15831 additions and 4 deletions

120
subapp-bletool/README.md Normal file
View file

@ -0,0 +1,120 @@
# KTag BLE Tool Subapp
A Jetpack Compose Android application for testing and debugging KTag BLE protocol packets.
## Overview
The BLE Tool provides two main functions:
1. **Advertiser**: Craft and broadcast custom KTag BLE advertisement packets
2. **Scanner**: Scan for and decode KTag devices broadcasting status packets
This tool is essential for development, testing, and debugging KTag devices and the protocol specification.
## Architecture
The app uses a simple activity-based architecture with Compose UI.
```
┌─────────────────────────────────────────────────────────┐
│ BleToolActivity │
│ (Advertiser Tab - Main) │
│ • Byte grid editor for packet construction │
│ • Preset packet templates │
│ • Start/Stop broadcasting │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ScannerActivity │
│ (Scanner Tab) │
│ • BLE scanning for KTag packets │
│ • Real-time packet decoding │
│ • Device list with parsed fields │
└─────────────────────────────────────────────────────────┘
```
## File Structure
```
src/main/java/club/clubk/ktag/apps/bletool/
├── BleToolActivity.kt # Main advertiser screen
├── ScannerActivity.kt # Scanner screen + packet parsing
├── AdvertisementPreset.kt # Preset packet definitions
├── ByteCellData.kt # Byte cell display data
├── PacketFieldUtils.kt # Field descriptions & colors
├── BleToolSubApp.kt # Subapp registration
├── BleToolInitializer.kt
└── ui/ # (Composables in main files)
```
## Features
### Advertiser
- **31-byte packet editor**: Visual grid showing all packet bytes
- **Header protection**: First 8 bytes (KTag header) are read-only
- **Hex editing**: Tap any byte to edit its value
- **Field highlighting**: Color-coded fields based on packet type
- **Presets**: Quick-load common packet types:
- Instigate Game (0x01)
- Event (0x02)
- Tag (0x03)
- Console (0x04)
- Status (0x05)
- Parameters (0x06)
- Hello (0x07)
### Scanner
- **KTag filtering**: Only shows devices with valid KTag packets
- **Real-time updates**: Live packet decoding as devices broadcast
- **Full packet parsing**: Decodes all 7 packet types with field details
- **Device tracking**: Groups packets by device address
## Packet Types
| Type | Name | Key Fields |
|------|------|------------|
| 0x01 | Instigate Game | Game length, Countdown time |
| 0x02 | Event | Target address, Event ID/Data |
| 0x03 | Tag | Team, Player, Damage, Color, Target |
| 0x04 | Console | ASCII message string |
| 0x05 | Status | Team, Health, Colors, State |
| 0x06 | Parameters | Target, Key/Value pairs |
| 0x07 | Hello | Version, Device type, Name |
## KTag Packet Structure
All KTag packets follow this structure:
```
Byte 0-3: "KTag" magic bytes (0x4B 0x54 0x61 0x67)
Byte 4: Packet type (0x01-0x07)
Byte 5: Event number (incrementing)
Byte 6-26: Type-specific payload
```
## Key Components
### BleAdvertiserScreen
Interactive packet editor with:
- `LazyVerticalGrid` of `ByteCell` components
- `ExposedDropdownMenu` for preset selection
- `ByteEditor` for hex value input
- Start/Stop broadcast button
### ScannerScreen
Device scanner with:
- `LazyColumn` of `PacketCard` components
- Sealed class hierarchy for packet types
- `parseKTagPacket()` for decoding raw bytes
## Dependencies
- Jetpack Compose (Material3)
- BLE (BluetoothLeScanner, BluetoothLeAdvertiser)
- Material Icons

View file

@ -0,0 +1,41 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.bletool"
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":core"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.startup)
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<application>
<activity
android:name=".BleToolActivity"
android:exported="false"
android:label="KTag BLE Tool" />
<activity
android:name=".ScannerActivity"
android:exported="false"
android:label="KTag Scanner" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="club.clubk.ktag.apps.bletool.BleToolInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,20 @@
package club.clubk.ktag.apps.bletool
data class AdvertisementPreset(
val name: String,
val data: ByteArray,
val description: String
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AdvertisementPreset
return name == other.name && data.contentEquals(other.data)
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}

View file

@ -0,0 +1,757 @@
package club.clubk.ktag.apps.bletool
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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.Search
import androidx.compose.material3.Scaffold
import club.clubk.ktag.apps.bletool.ui.theme.KTagBLEToolTheme
private const val TAG = "BLE Tool"
val advertisementPresets = listOf(
AdvertisementPreset(
"01 Instigate Game",
byteArrayOf(
0x01.toByte(), // Packet Type: Instigate Game
0x00.toByte(), // Event Number
0x00.toByte(),
0x00.toByte(),
0x00.toByte(),
0x00.toByte(), // Game length (ms)
0x88.toByte(),
0x13.toByte(),
0x00.toByte(),
0x00.toByte(), // Time remaining until Countdown (ms)s
),
"KTag Instigate Game Packet"
),
AdvertisementPreset(
"02 Event",
byteArrayOf(
0x02.toByte(), // Packet Type: Event Game
0x00.toByte(), // Event Number
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(), // Target Bluetooth Device Address
0x00.toByte(),
0x00.toByte(),
0x00.toByte(),
0x00.toByte(), // Event ID
0x00.toByte(),
0x00.toByte(),
0x00.toByte(),
0x00.toByte(), // Event Data
),
"KTag Event Packet"
),
AdvertisementPreset(
"03 Tag",
byteArrayOf(
0x03.toByte(), // Packet Type: Tag
0x00.toByte(), // Event Number
0x00.toByte(), // Tx Power level (dBm)
0x00.toByte(), // Protocol
0x00.toByte(), // Team ID
0x00.toByte(), // Player ID
0x0A.toByte(),
0x00.toByte(), // Damage
0x00.toByte(),
0x00.toByte(),
0x00.toByte(),
0x00.toByte(), // Color
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(), // Target Bluetooth Device Address
),
"KTag Tag Packet"
),
AdvertisementPreset(
"04 Console",
byteArrayOf(
0x04.toByte(), // Packet Type: Console
0x00.toByte(), // Event Number
),
"KTag Console Packet"
),
AdvertisementPreset(
"05 Status",
byteArrayOf(
0x05.toByte(), // Packet Type: Status
0x00.toByte(), // Event Number
0x00.toByte(), // Tx Power level (dBm)
0x00.toByte(), // Protocol
0x02.toByte(), // Team ID
0x00.toByte(), // Player ID
0x64.toByte(), 0x00.toByte(), // Health
0x64.toByte(), 0x00.toByte(), // Maximum Health
0xFE.toByte(), 0x00.toByte(), 0x00.toByte(), 0xFF.toByte(), // Primary Color
0xFE.toByte(), 0xFF.toByte(), 0x00.toByte(), 0x00.toByte(), // Secondary Color
0x07.toByte(), // SystemK Top-Level State
),
"KTag Status Packet"
),
AdvertisementPreset(
"06 Parameters",
byteArrayOf(
0x06.toByte(), // Packet Type: Parameters
0x00.toByte(), // Event Number
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(),
0xFF.toByte(), // Target Bluetooth Device Address
0x02.toByte(), // Subtype: Request Parameter Change
0x01.toByte(),
0x00.toByte(), // Key 1: Team ID
0x02.toByte(),
0x00.toByte(),
0x00.toByte(),
0x00.toByte(), // Value 1: 2
0xFF.toByte(),
0xFF.toByte(), // Key 2: Unused
0x00.toByte(),
0x00.toByte(),
0x00.toByte(),
0x00.toByte(), // Value 2: Unused
),
"KTag Parameters Packet"
),
AdvertisementPreset(
"07 Hello",
byteArrayOf(
0x07.toByte(), // Packet Type: Hello
0x00.toByte(), // Event Number
0x01.toByte(), // SystemK Major Version
0x00.toByte(), // SystemK Minor Version
0x02.toByte(), //
0x00.toByte(), // Device Type: Mobile App
0x02.toByte(), // Team ID
0x4B.toByte(), // 'K'
0x54.toByte(), // 'T'
0x61.toByte(), // 'a'
0x67.toByte(), // 'g'
0x20.toByte(), // ' '
0x42.toByte(), // 'B'
0x4C.toByte(), // 'L'
0x45.toByte(), // 'E'
0x20.toByte(), // ' '
0x54.toByte(), // 'T'
0x6F.toByte(), // 'o'
0x6F.toByte(), // 'o'
0x6C.toByte(), // 'l'
0x00.toByte(), // Device Name: "KTag BLE Tool"
),
"KTag Hello Packet"
),
AdvertisementPreset(
"Empty",
ByteArray(23) { 0 },
"All zeros (clear current data)"
)
)
@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 BleToolActivity : ComponentActivity() {
private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.entries.all { it.value }
Log.d(TAG, "Permission results: ${permissions.map { "${it.key}: ${it.value}" }}")
if (allGranted) {
Log.i(TAG, "All permissions granted")
setContent {
KTagBLEToolTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainScreen()
}
}
}
} else {
val deniedPermissions = permissions.filter { !it.value }.keys
Log.w(TAG, "Some permissions denied: $deniedPermissions")
setContent {
KTagBLEToolTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
PermissionDeniedScreen(
deniedPermissions = deniedPermissions.toList()
) { requestPermissions() }
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate called")
checkAndRequestPermissions()
}
private fun checkAndRequestPermissions() {
Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
if (hasRequiredPermissions()) {
Log.i(TAG, "All required permissions already granted")
setContent {
KTagBLEToolTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainScreen()
}
}
}
} else {
Log.i(TAG, "Requesting permissions")
requestPermissions()
}
}
private fun hasRequiredPermissions(): Boolean {
return requiredPermissions.all { permission ->
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestPermissions() {
Log.d(TAG, "Launching permission request")
permissionLauncher.launch(requiredPermissions)
}
}
@Composable
fun PermissionDeniedScreen(
deniedPermissions: List<String>,
onRequestPermissions: () -> Unit
) {
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "The following permissions are required:",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 8.dp)
)
deniedPermissions.forEach { permission ->
Text(
text = "${permission.split(".").last()}",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 4.dp)
)
}
Button(
onClick = {
Log.d(TAG, "Permission request button clicked")
onRequestPermissions()
},
modifier = Modifier.padding(top = 16.dp)
) {
Text("Grant Permissions")
}
}
}
private const val N_HEADER_BYTES = 8
@Composable
fun TitleBox() {
Box(
modifier = Modifier
.padding(16.dp)
.border(2.dp, MaterialTheme.colorScheme.primary)
.padding(16.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column {
Text(
text = "KTag BLE Tool",
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "based on the specification 0.12",
style = MaterialTheme.typography.labelSmall.copy(fontSize = 16.sp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BleAdvertiserScreen() {
Log.d(TAG, "Initializing BLE Advertiser screen")
val context = LocalContext.current
var advertisementData by remember {
mutableStateOf(ByteArray(31) { 0 }.apply {
this[0] = 0x1E.toByte() // Length (30 bytes of data follow)
this[1] = 0xFF.toByte() // Type (Manufacturer Specific Data)
this[2] = 0xFF.toByte() // Manufacturer ID (Other) - First byte
this[3] = 0xFF.toByte() // Manufacturer ID (Other) - Second byte
this[4] = 0x4B.toByte() // KTag Packet Indicator - 'K'
this[5] = 0x54.toByte() // KTag Packet Indicator - 'T'
this[6] = 0x61.toByte() // KTag Packet Indicator - 'a'
this[7] = 0x67.toByte() // KTag Packet Indicator - 'g'
})
}
var isAdvertising by remember { mutableStateOf(false) }
var selectedByteIndex by remember { mutableIntStateOf(-1) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var expandedDropdown by remember { mutableStateOf(false) }
var selectedPreset by remember { mutableStateOf<AdvertisementPreset?>(null) }
Column(
modifier = Modifier.padding(16.dp)
) {
TitleBox()
val byteCells = remember(advertisementData) {
val packetType = advertisementData[8]
advertisementData.mapIndexed { index, byte ->
ByteCellData(
value = byte,
isHeader = index < N_HEADER_BYTES,
description = PacketFieldUtils.getFieldDescription(packetType, index),
backgroundColor = PacketFieldUtils.getFieldColor(packetType, index)
)
}
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
errorMessage?.let { message ->
Log.e(TAG, "Showing error message: $message")
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
) {
Text(
text = message,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(8),
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(8.dp)
) {
items(byteCells.size) { index ->
ByteCell(
data = byteCells[index],
isSelected = selectedByteIndex == index,
onClick = {
if (!byteCells[index].isHeader) {
Log.d(TAG, "Selected byte at index $index")
selectedByteIndex = index
}
}
)
}
}
ExposedDropdownMenuBox(
expanded = expandedDropdown,
onExpandedChange = { expandedDropdown = it },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
OutlinedTextField(
value = selectedPreset?.name ?: "Select Preset",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expandedDropdown,
onDismissRequest = { expandedDropdown = false }
) {
advertisementPresets.forEach { preset ->
DropdownMenuItem(
text = {
Column {
Text(preset.name)
Text(
preset.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
Log.d(TAG, "Selected preset: ${preset.name}")
selectedPreset = preset
advertisementData = advertisementData.copyOf().also {
preset.data.copyInto(it, destinationOffset = 8)
}
expandedDropdown = false
selectedByteIndex = -1
}
)
}
}
}
if (selectedByteIndex >= N_HEADER_BYTES) {
ByteEditor(
currentValue = advertisementData[selectedByteIndex],
onValueChange = { newValue ->
Log.d(
TAG,
"Updating byte at index $selectedByteIndex to ${
String.format(
"%02X",
newValue
)
}"
)
advertisementData = advertisementData.copyOf().also {
it[selectedByteIndex] = newValue
}
}
)
}
Button(
onClick = {
try {
if (isAdvertising) {
Log.i(TAG, "Stopping advertisement")
stopAdvertising(context)
} else {
val payloadData =
advertisementData.copyOfRange(4, advertisementData.size)
Log.i(
TAG,
"Starting advertisement with data: ${
advertisementData.joinToString {
String.format(
"%02X",
it
)
}
}"
)
startAdvertising(context, payloadData)
}
isAdvertising = !isAdvertising
errorMessage = null
} catch (e: Exception) {
Log.e(TAG, "Error during advertisement operation", e)
errorMessage = "Error: ${e.message}"
}
},
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Text(if (isAdvertising) "Stop Broadcasting" else "Start Broadcasting")
}
}
}
}
@Composable
fun ByteCell(
data: ByteCellData,
isSelected: Boolean,
onClick: () -> Unit
) {
val backgroundColor = when {
data.isHeader -> MaterialTheme.colorScheme.secondaryContainer
isSelected -> MaterialTheme.colorScheme.primary
data.backgroundColor != null -> data.backgroundColor
else -> MaterialTheme.colorScheme.primaryContainer
}
Column {
Card(
modifier = Modifier
.padding(N_HEADER_BYTES.dp)
.size(48.dp),
border = if (isSelected && !data.isHeader)
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
else null,
colors = CardDefaults.cardColors(containerColor = backgroundColor),
onClick = onClick
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = String.format("%02X", data.value),
style = MaterialTheme.typography.bodyMedium
)
}
}
if (data.description != "") {
Text(
text = data.description,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = N_HEADER_BYTES.dp),
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun ByteEditor(
currentValue: Byte,
onValueChange: (Byte) -> Unit
) {
var textValue by remember(currentValue) {
mutableStateOf(String.format("%02X", currentValue))
}
OutlinedTextField(
value = textValue,
onValueChange = { newValue ->
if (newValue.length <= 2 && newValue.all { it.isDigit() || it in 'A'..'F' || it in 'a'..'f' }) {
textValue = newValue.uppercase()
if (newValue.length == 2) {
onValueChange(newValue.toInt(16).toByte())
}
}
},
modifier = Modifier
.padding(16.dp)
.width(96.dp),
label = { Text("Byte Value (Hex)") },
singleLine = true
)
}
@SuppressLint("MissingPermission")
private fun startAdvertising(context: Context, data: ByteArray) {
Log.d(TAG, "Attempting to start advertising")
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter ?: run {
Log.e(TAG, "Bluetooth not supported on this device")
throw IllegalStateException("Bluetooth not supported")
}
if (!bluetoothAdapter.isEnabled) {
Log.e(TAG, "Bluetooth is not enabled")
throw IllegalStateException("Bluetooth is not enabled")
}
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser ?: run {
Log.e(TAG, "BLE advertising not supported on this device")
throw IllegalStateException("BLE advertising not supported")
}
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(false)
.setTimeout(0)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.build()
val maxManufacturerDataSize = 27 // 31 - 4 bytes overhead
val truncatedData = if (data.size > maxManufacturerDataSize) {
Log.w(
TAG,
"Data exceeded maximum size (${data.size} > $maxManufacturerDataSize bytes), truncating"
)
data.copyOfRange(0, maxManufacturerDataSize)
} else {
data
}
Log.d(TAG, "Advertisement structure:")
Log.d(TAG, "- Total payload max: 31 bytes")
Log.d(TAG, "- Overhead: 4 bytes (Length: 1, Type: 1, Manufacturer ID: 2)")
Log.d(TAG, "- Available for manufacturer data: $maxManufacturerDataSize bytes")
Log.d(TAG, "- Actual data size: ${truncatedData.size} bytes")
Log.d(TAG, "- Data: ${truncatedData.joinToString(" ") { String.format("%02X", it) }}")
val advertiseData = AdvertiseData.Builder()
.addManufacturerData(0xFFFF, truncatedData)
.build()
Log.i(
TAG, "Starting advertisement with settings: Mode=${settings.mode}, " +
"TxPower=${settings.txPowerLevel}, Connectable=${settings.isConnectable}"
)
advertiser.startAdvertising(settings, advertiseData, advertisingCallback)
}
@SuppressLint("MissingPermission")
private fun stopAdvertising(context: Context) {
Log.d(TAG, "Attempting to stop advertising")
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
advertiser?.let {
Log.i(TAG, "Stopping advertisement")
it.stopAdvertising(advertisingCallback)
} ?: Log.w(TAG, "Cannot stop advertising - advertiser is null")
}
private val advertisingCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
super.onStartSuccess(settingsInEffect)
Log.i(
TAG, "Advertisement started successfully with settings: " +
"Mode=${settingsInEffect.mode}, TxPower=${settingsInEffect.txPowerLevel}"
)
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
val errorMessage = when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED -> "Already started"
ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large"
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error"
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
else -> "Unknown error $errorCode"
}
Log.e(TAG, "Failed to start advertising: $errorMessage")
}
}

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.bletool
import android.content.Context
import androidx.startup.Initializer
import club.clubk.ktag.apps.core.SubAppRegistry
class BleToolInitializer : Initializer<Unit> {
override fun create(context: Context) {
SubAppRegistry.register(BleToolSubApp())
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

View file

@ -0,0 +1,15 @@
package club.clubk.ktag.apps.bletool
import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.core.SubApp
class BleToolSubApp : SubApp {
override val id = "bletool"
override val name = "BLE Tool"
override val icon = R.drawable.ic_bletool
override fun createIntent(context: Context): Intent {
return Intent(context, BleToolActivity::class.java)
}
}

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.apps.bletool
import androidx.compose.ui.graphics.Color
data class ByteCellData(
val value: Byte,
val isHeader: Boolean,
val description: String,
val backgroundColor: Color? = null
)

View file

@ -0,0 +1,183 @@
package club.clubk.ktag.apps.bletool
import androidx.compose.ui.graphics.Color
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
import club.clubk.ktag.apps.core.ui.theme.KTagRed
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
// Pastel versions of KTag colors for field highlighting
private val LightBlue = KTagBlue.copy(alpha = 0.25f)
private val LightGreen = KTagGreen.copy(alpha = 0.25f)
private val LightRed = KTagRed.copy(alpha = 0.25f)
private val LightYellow = KTagYellow.copy(alpha = 0.25f)
private val LightPurple = KTagPurple.copy(alpha = 0.25f)
private val LightGray = Color(0xFFF5F5F5)
object PacketFieldUtils {
fun getFieldDescription(packetType: Byte, index: Int): String {
return when (index) {
0 -> "Length" // Always "Length"
1 -> "Type" // Always "Type"
2 -> "Mfg ID" // Always "Mfg ID"
3 -> "Mfg ID" // Always "Mfg ID"
4 -> "'K'" // Always "'K'"
5 -> "'T'" // Always "'T'"
6 -> "'a'" // Always "'a'"
7 -> "'g'" // Always "'g'"
8 -> when (packetType) {
0x01.toByte() -> "Instigate Game"
0x02.toByte() -> "Event"
0x03.toByte() -> "Tag"
0x04.toByte() -> "Console"
0x05.toByte() -> "Status"
0x06.toByte() -> "Parameters"
0x07.toByte() -> "Hello"
else -> "Packet Type"
}
9 -> "Event Number"
else -> when (packetType) {
0x01.toByte() -> when (index) { // Instigate Game packet fields
in 10..13 -> "Game Length (ms)"
in 14..17 -> "Time till Countdown (ms)"
else -> ""
}
0x02.toByte() -> when (index) { // Event packet fields
in 10..15 -> "Target Address"
in 16..19 -> "Event ID"
in 20..23 -> "Event Data"
else -> ""
}
0x03.toByte() -> when (index) { // Tag packet fields
10 -> "Tx Pwr (dBm)"
11 -> "Protocol"
12 -> "Team ID"
13 -> "Player ID"
14, 15 -> "Damage"
in 16..19 -> "Color"
in 20..25 -> "Target Address"
else -> ""
}
0x04.toByte() -> when (index) { // Console packet fields
in 10..30 -> "Console String"
else -> ""
}
0x05.toByte() -> when (index) { // Status packet fields
10 -> "Tx Pwr (dBm)"
11 -> "Protocol"
12 -> "Team ID"
13 -> "Player ID"
14, 15 -> "Health"
16, 17 -> "Max Health"
in 18..21 -> "Primary Color"
in 22..25 -> "Secondary Color"
26 -> "SystemK State"
else -> ""
}
0x06.toByte() -> when (index) { // Parameters packet fields
in 10..15 -> "Target Address"
16 -> "Subtype"
17, 18 -> "Key 1"
in 19..22 -> "Value 1"
23, 24 -> "Key 2"
in 25..28 -> "Value 2"
else -> ""
}
0x07.toByte() -> when (index) { // Hello packet fields
10 -> "SystemK Major Version"
11 -> "SystemK Minor Version"
12, 13 -> "Device Type"
14 -> "Team ID"
in 15..30 -> "Device Name"
else -> ""
}
else -> ""
}
}
}
fun getFieldColor(packetType: Byte, index: Int): Color? {
// Header bytes always return null to use default header color
if (index < 8) return null
// Packet type byte is always a distinct color
if (index == 8) return LightBlue
// Event number is always the same color across all packet types
if (index == 9) return LightGreen
return when (packetType) {
0x01.toByte() -> when (index) { // Instigate Game packet
in 10..13 -> LightYellow // Game Length
in 14..17 -> LightGreen // Time until Countdown
else -> LightGray
}
0x02.toByte() -> when (index) { // Event packet
in 10..15 -> LightBlue // Target Address
in 16..19 -> LightPurple // Event ID
in 20..23 -> LightGreen // Event Data
else -> LightGray
}
0x03.toByte() -> when (index) { // Tag packet
10 -> LightYellow // Tx Power
11 -> LightGreen // Protocol
12 -> LightRed // Team ID
13 -> LightPurple // Player ID
in 14..15 -> LightRed // Damage
in 16..19 -> LightGreen // Color
in 20..25 -> LightBlue // Target Address
else -> LightGray
}
0x04.toByte() -> when (index) { // Console packet
in 10..30 -> LightYellow // Console String
else -> LightGray
}
0x05.toByte() -> when (index) { // Status packet
10 -> LightYellow // Tx Power
11 -> LightGreen // Protocol
12 -> LightRed // Team ID
13 -> LightPurple // Player ID
14, 15 -> LightRed // Health
16, 17 -> LightGreen // Maximum Health
in 18..21 -> LightBlue // Primary Color
in 22..25 -> LightPurple // Secondary Color
26 -> LightYellow // SystemK State
else -> LightGray
}
0x06.toByte() -> when (index) { // Parameters packet
in 10..15 -> LightBlue // Target Address
16 -> LightYellow // Subtype
17, 18 -> LightPurple // Key 1
in 19..22 -> LightGreen // Value 1
23, 24 -> LightPurple // Key 2
in 25..28 -> LightGreen // Value 2
else -> LightGray
}
0x07.toByte() -> when (index) { // Hello packet
10 -> LightYellow // SystemK Major Version
11 -> LightGreen // SystemK Minor Version
12, 13 -> LightBlue // Device Type
14 -> LightRed // Team ID
in 15..30 -> LightPurple // Device Name
else -> LightGray
}
else -> LightGray
}
}
}

View file

@ -0,0 +1,506 @@
package club.clubk.ktag.apps.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.Intent
import android.location.LocationManager
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import club.clubk.ktag.apps.core.ble.Packet
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import club.clubk.ktag.apps.bletool.ui.theme.KTagBLEToolTheme
private const val TAG = "BLE Scanner"
class ScannerActivity : ComponentActivity() {
private var isScanning by mutableStateOf(false)
private var showLocationDialog by mutableStateOf(false)
private val scannedDevices = mutableStateMapOf<String, KTagPacket>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
KTagBLEToolTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ScannerScreen(
isScanning = isScanning,
devices = scannedDevices,
onStartScan = { startScanning() },
onStopScan = { stopScanning() }
)
if (showLocationDialog) {
AlertDialog(
onDismissRequest = { showLocationDialog = false },
title = { Text("Location Services Required") },
text = { Text("BLE scanning requires Location Services to be enabled. Please enable Location in your device settings.") },
confirmButton = {
TextButton(onClick = {
showLocationDialog = false
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}) {
Text("Open Settings")
}
},
dismissButton = {
TextButton(onClick = { showLocationDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
}
}
private fun isLocationEnabled(): Boolean {
val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
return locationManager.isLocationEnabled
}
@SuppressLint("MissingPermission")
private fun startScanning() {
if (!isLocationEnabled()) {
Log.w(TAG, "Location services are disabled, cannot scan")
showLocationDialog = true
return
}
scannedDevices.clear()
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
val scanner = bluetoothManager.adapter.bluetoothLeScanner ?: run {
Log.e(TAG, "BLE scanning not supported")
return
}
val filter = ScanFilter.Builder()
.setManufacturerData(
0xFFFF,
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(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) {
SCAN_FAILED_ALREADY_STARTED -> "Already started"
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
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()
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)
) {
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))
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 ParametersPacket -> {
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", String.format("SystemK v%d.%02d", packet.majorVersion, packet.minorVersion))
PacketRow("Device Type", Packet.getDeviceTypeName(packet.deviceType))
PacketRow("Team ID", packet.teamId.toString())
PacketRow("Device Name", packet.deviceName)
}
}
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 ParametersPacket(
override val type: Int,
override val typeName: String = "Parameters",
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().let { bytes ->
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
if (nullPos >= 0) {
bytes.slice(0 until nullPos).toByteArray()
} else {
bytes
}.toString(Charsets.US_ASCII).trim()
}
)
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 -> ParametersPacket(
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().let { bytes ->
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
if (nullPos >= 0) {
bytes.slice(0 until nullPos).toByteArray()
} else {
bytes
}.toString(Charsets.US_ASCII).trim()
}
)
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
)
}
}
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
}
private fun bytesToMacAddress(data: ByteArray, offset: Int): String {
return (0..5).joinToString(":") {
String.format("%02X", data[offset + it])
}
}

View file

@ -0,0 +1,17 @@
package club.clubk.ktag.apps.bletool.ui.theme
// Re-export core colors for use in this module
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
import club.clubk.ktag.apps.core.ui.theme.KTagRed
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
// BLE Tool color aliases for clarity in module-specific code
val BleToolGreen = KTagGreen
val BleToolBlue = KTagBlue
val BleToolRed = KTagRed
val BleToolYellow = KTagYellow
val BleToolPurple = KTagPurple
val BleToolDarkGray = KTagDarkGray

View file

@ -0,0 +1,54 @@
package club.clubk.ktag.apps.bletool.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
private val DarkColorScheme = darkColorScheme(
primary = KTagYellow,
onPrimary = KTagDarkGray,
secondary = KTagGreen,
onSecondary = KTagDarkGray,
tertiary = KTagPurple
)
private val LightColorScheme = lightColorScheme(
primary = KTagYellow,
onPrimary = KTagDarkGray,
secondary = KTagGreen,
onSecondary = KTagDarkGray,
tertiary = KTagPurple
)
@Composable
fun KTagBLEToolTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,17 @@
package club.clubk.ktag.apps.bletool.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Cursive,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB