Initial public release.
This commit is contained in:
parent
ed31acd60f
commit
58d87b11b7
249 changed files with 15831 additions and 4 deletions
120
subapp-bletool/README.md
Normal file
120
subapp-bletool/README.md
Normal 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
|
||||
41
subapp-bletool/build.gradle.kts
Normal file
41
subapp-bletool/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
37
subapp-bletool/src/main/AndroidManifest.xml
Normal file
37
subapp-bletool/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
BIN
subapp-bletool/src/main/res/drawable/ic_bletool.webp
Normal file
BIN
subapp-bletool/src/main/res/drawable/ic_bletool.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
Loading…
Add table
Add a link
Reference in a new issue