diff --git a/BLE_Tool_Icon.webp b/BLE_Tool_Icon.webp new file mode 100644 index 0000000..a98168b Binary files /dev/null and b/BLE_Tool_Icon.webp differ diff --git a/README.md b/README.md index 63c3070..ba72140 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ -# Android-BLE-Tool +# Android BLE Tool + +![BLE Tool Icon](BLE_Tool_Icon.webp) + +## Overview + +This software is used primarily for testing the BLE interface to KTag Devices. + +The primary documentation for KTag BLE is on the KTag website at https://ktag.clubk.club/Technology/BLE/. + +You can ask questions (and get answers!) about this software on the KTag forum at https://forum.ktag.clubk.club/c/software/. + +## License: [AGPL-3.0-or-later](https://spdx.org/licenses/AGPL-3.0-or-later.html) + +This software is part of the KTag project, a DIY laser tag game with customizable features and wide interoperability. + +🛡️ 🃞 + +Copyright © 2025 Joseph P. Kearney and the KTag developers. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +There should be a copy of the GNU Affero General Public License in the [LICENSE](LICENSE) +file in the root of this repository. If not, see . -Android app for testing BLE functionality. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..add0cbd --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "club.clubk.ktag.bletool" + compileSdk = 35 + + defaultConfig { + applicationId = "club.clubk.ktag.bletool" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/club/clubk/ktag/bletool/ExampleInstrumentedTest.kt b/app/src/androidTest/java/club/clubk/ktag/bletool/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..15f381d --- /dev/null +++ b/app/src/androidTest/java/club/clubk/ktag/bletool/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package club.clubk.ktag.bletool + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("club.clubk.ktag.bletool", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6888c81 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..ff4ca94 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/club/clubk/ktag/bletool/AdvertisementPreset.kt b/app/src/main/java/club/clubk/ktag/bletool/AdvertisementPreset.kt new file mode 100644 index 0000000..3a05bef --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/bletool/AdvertisementPreset.kt @@ -0,0 +1,20 @@ +package club.clubk.ktag.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 + } +} \ No newline at end of file diff --git a/app/src/main/java/club/clubk/ktag/bletool/ByteCellData.kt b/app/src/main/java/club/clubk/ktag/bletool/ByteCellData.kt new file mode 100644 index 0000000..0c56e39 --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/bletool/ByteCellData.kt @@ -0,0 +1,7 @@ +package club.clubk.ktag.bletool + +data class ByteCellData( + val value: Byte, + val isHeader: Boolean, + val description: String +) \ No newline at end of file diff --git a/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt b/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt new file mode 100644 index 0000000..0cb959c --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/bletool/MainActivity.kt @@ -0,0 +1,716 @@ +package club.clubk.ktag.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.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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp + +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 Instigate Game 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( + "Empty", + ByteArray(23) { 0 }, + "All zeros - clear current data" + ) +) + +class MainActivity : ComponentActivity() { + private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_CONNECT + ) + } else { + arrayOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN + ) + } + + 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 { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BleAdvertiserScreen() + } + } + } + } else { + Log.w(TAG, "Some permissions denied: ${permissions.filter { !it.value }.keys}") + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PermissionDeniedScreen { 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 { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BleAdvertiserScreen() + } + } + } + } 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(onRequestPermissions: () -> Unit) { + Log.d(TAG, "Showing permission denied screen") + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Bluetooth permissions are required to use this app", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + Button( + onClick = { + Log.d(TAG, "Permission request button clicked") + onRequestPermissions() + } + ) { + 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.10", + 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 + // Now store all 31 bytes (8 header + 23 data) + var advertisementData by remember { + mutableStateOf(ByteArray(31) { 0 }.apply { + // Initialize header bytes + 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(null) } + var expandedDropdown by remember { mutableStateOf(false) } + var selectedPreset by remember { mutableStateOf(null) } + + Column( + modifier = Modifier.padding(16.dp) + ) { + TitleBox() + + // Create list of byte cells with their properties + val byteCells = remember(advertisementData) { + advertisementData.mapIndexed { index, byte -> + ByteCellData( + value = byte, isHeader = index < N_HEADER_BYTES, description = 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'" + 9 -> "Event Number" + else -> { + when (advertisementData[8]) { + 0x01.toByte() -> when(index){ + 8 -> "Instigate Game" // Packet Type 01 + else -> "" + } + 0x02.toByte() -> when(index){ + 8 -> "Event" // Packet Type 02 + else -> "" + } + 0x03.toByte() -> when(index){ + 8 -> "Tag" // Packet Type 03 + 10 -> "Tx Pwr (dBm)" + 11 -> "Protocol" + 12 -> "Team ID" + 13 -> "Player ID" + 14 -> "Damage" + 15 -> "Damage" + 16 -> "Color" + 17 -> "Color" + 18 -> "Color" + 19 -> "Color" + 20 -> "Target Address" + 21 -> "Target Address" + 22 -> "Target Address" + 23 -> "Target Address" + 24 -> "Target Address" + 25 -> "Target Address" + else -> "" + } + 0x04.toByte() -> when(index){ + 8 -> "Console" // Packet Type 04 + else -> "" + } + 0x05.toByte() -> when(index){ + 8 -> "Status" // Packet Type 05 + else -> "" + } + 0x06.toByte() -> when(index){ + 8 -> "Configuration" // Packet Type 06 + else -> "" + } + 0x07.toByte() -> when(index){ + 8 -> "Hello" // Packet Type 07 + else -> "" + } + else -> when(index){ + 8 -> "Packet Type" + else -> "" + } + } + } + } + ) + } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Error message display (unchanged) + 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 + ) + } + } + + // Byte grid + 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 + } + } + ) + } + } + + // Preset selector + 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 + // Copy preset data after the header + advertisementData = advertisementData.copyOf().also { + preset.data.copyInto(it, destinationOffset = 8) + } + expandedDropdown = false + selectedByteIndex = -1 + } + ) + } + } + } + + // Byte editor (only for non-header bytes) + 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 + } + } + ) + } + + // Advertisement control button + Button( + onClick = { + try { + if (isAdvertising) { + Log.i(TAG, "Stopping advertisement") + stopAdvertising(context) + } else { + // Skip the first four bytes (length, type, mfg ID, mfg ID) bytes when sending, because they get + // reconstructed later. + 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 + 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() + + // Calculate available space for manufacturer data + // 2 bytes for manufacturer ID + // Maximum total payload is 31 bytes + // Format: [Length (1 byte)] [Type (1 byte)] [Manufacturer ID (2 bytes)] [Data (remaining bytes)] + 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") + } +} \ No newline at end of file diff --git a/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Color.kt b/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Color.kt new file mode 100644 index 0000000..6f9b7e5 --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package club.clubk.ktag.bletool.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Theme.kt b/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Theme.kt new file mode 100644 index 0000000..aa0d91d --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package club.clubk.ktag.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 + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun KTagBLEToolTheme ( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Type.kt b/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Type.kt new file mode 100644 index 0000000..81fb5ce --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/bletool/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package club.clubk.ktag.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 + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Cursive, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..d05fb15 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d524a71 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5752070 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..5fd2938 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..0c42670 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5837e95 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..7abfc84 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..3cbfb17 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..80a6946 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..14c2e83 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..839683b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f3301ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..784e91c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..49f1c3d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..53df188 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..dc1469f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + KTag BLE Tool + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..319263c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +