Initial public release.

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

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.aab
*.apk
output-metadata.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
# Claude
.claude/
CLAUDE.md
Reference/
# TensorFlow
subapp-sentry/src/main/assets/efficientdet_lite0.tflite

View file

@ -219,8 +219,8 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
Android-KTag-Apps
Copyright (C) 2026 Software
KTag-Apps-Android
Copyright (C) 2026 KTag
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.

390
README.md
View file

@ -1,3 +1,389 @@
# Android-KTag-Apps
# KTag Apps
A modular Android app that serves as a launcher for a collection of related subapps. The main screen displays a grid of icons, each launching an independent subapp. Each subapp is its own Gradle module, making it easy to develop and add new ones without touching existing code.
## Project Structure
- **`app`** — Main launcher module. Displays the subapp grid and handles navigation.
- **`core`** — Shared interfaces (`SubApp`, `SubAppRegistry`) that all modules depend on.
- **`shared-services`** — Common settings infrastructure (`SettingsSubApp`, `BaseSettingsActivity`, `SummarizedEditTextPreference`) for subapps with user preferences. Also provides `SharedMqttClient`, a singleton MQTT client shared by all subapps, `DeviceInfoMqttSync` for cross-device info synchronization, and `LocationPublisher` for GPS position reporting.
- [**`subapp-sample`**](subapp-sample/README.md) — Example subapp demonstrating the full pattern.
- [**`subapp-bletool`**](subapp-bletool/README.md) — Tool for debugging KTag BLE issues. Identical functionality to the old [Android BLE Tool](https://git.ktag.clubk.club/Software/Android-BLE-Tool).
- [**`subapp-koth`**](subapp-koth/README.md) — App for hosting King of the Hill games (with [MQTT](https://mqtt.org/)).
- [**`subapp-medic`**](subapp-medic/README.md) — App for simulating a medic (with proximity-based healing and [MQTT](https://mqtt.org/)).
- [**`subapp-terminal`**](subapp-terminal/README.md) — USB serial terminal for communicating with KTag devices using [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android).
- [**`subapp-mine`**](subapp-mine/README.md) — App for automatically tagging nearby devices via BLE.
- [**`subapp-konfigurator`**](subapp-konfigurator/README.md) — App for configuring KTag laser tag devices via BLE and coordinating game sessions.
- [**`subapp-sentry`**](subapp-sentry/README.md) — Autonomous sentry gun app that uses the device camera and on-device ML (MediaPipe / EfficientDet-Lite0) to detect people and fire the laser tag gun via USB serial when a person intersects the crosshair.
- **`mqtt-broker`** — Embedded MQTT broker module using [Moquette](https://github.com/moquette-io/moquette). Runs as a foreground service with optional SSL, authentication, and mDNS discovery. Also hosts the unified "MQTT Settings" page (client connection settings, broker settings, and mDNS broker discovery). Configured via the overflow menu in the main launcher.
## Building
Open the project in Android Studio and sync Gradle, or run:
```bash
./gradlew assembleDebug
```
## KTag Colors
The KTag color palette should be used consistently across all subapps:
| Color | Hex | Usage |
|------------|-----------|--------------------------------------------|
| Green | `#4BA838` | Success states, positive indicators |
| Blue | `#4D6CFA` | Blue team, links, interactive elements |
| Red | `#F34213` | Red team, warnings, destructive actions |
| Yellow | `#FFC857` | Highlights, accents |
| Purple | `#9B59B6` | All teams, combined team indicators |
| Dark Gray | `#323031` | Backgrounds, text, icons |
These colors are defined in each module's `Color.kt` file (e.g., `KTagGreen`, `KTagBlue`, `KTagRed`, `KTagYellow`, `KTagPurple`, `KTagDarkGray`).
## Adding a New SubApp
### 1. Create the module directory
```
subapp-yourname/
└── src/main/
├── AndroidManifest.xml
├── java/club/clubk/ktag/apps/yourname/
│ ├── YourSubApp.kt
│ ├── YourActivity.kt
│ └── YourInitializer.kt
└── res/drawable/
└── ic_yourname.xml
```
### 2. Add `build.gradle.kts`
```kotlin
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.yourname"
compileSdk = 34
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)
}
```
### 3. Implement the `SubApp` interface
```kotlin
// YourSubApp.kt
package club.clubk.ktag.apps.yourname
import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.core.SubApp
class YourSubApp : SubApp {
override val id = "yourname"
override val name = "Your App"
override val icon = R.drawable.ic_yourname
override fun createIntent(context: Context): Intent {
return Intent(context, YourActivity::class.java)
}
}
```
### 4. Create the entry Activity
```kotlin
// YourActivity.kt
package club.clubk.ktag.apps.yourname
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class YourActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Your UI here
}
}
}
```
### 5. Create the Initializer
This registers your subapp with the launcher automatically on app startup:
```kotlin
// YourInitializer.kt
package club.clubk.ktag.apps.yourname
import android.content.Context
import androidx.startup.Initializer
import club.clubk.ktag.apps.core.SubAppRegistry
class YourInitializer : Initializer<Unit> {
override fun create(context: Context) {
SubAppRegistry.register(YourSubApp())
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
```
### 6. Declare the Activity and Initializer in `AndroidManifest.xml`
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".YourActivity"
android:exported="false"
android:label="Your App" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="club.clubk.ktag.apps.yourname.YourInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>
```
### 7. Add a drawable icon
Place a vector drawable at `src/main/res/drawable/ic_yourname.xml`. This is what appears in the launcher grid.
### 8. Register the module with the project
In `settings.gradle.kts`, add:
```kotlin
include(":subapp-yourname")
```
In `app/build.gradle.kts`, add the dependency:
```kotlin
implementation(project(":subapp-yourname"))
```
Sync Gradle and run the app — your new subapp will appear in the launcher grid.
## Adding Settings to a SubApp
If your subapp needs user-configurable settings, use the `shared-services` module.
### 1. Add the dependency
In your subapp's `build.gradle.kts`:
```kotlin
dependencies {
implementation(project(":core"))
implementation(project(":shared-services"))
// ... other dependencies
}
```
### 2. Implement `SettingsSubApp` instead of `SubApp`
```kotlin
// YourSubApp.kt
package club.clubk.ktag.apps.yourname
import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.sharedservices.SettingsSubApp
class YourSubApp : SettingsSubApp {
override val id = "yourname"
override val name = "Your App"
override val icon = R.drawable.ic_yourname
override val settingsPreferencesResId = R.xml.settings_pref
override val usesMqtt = true // set to false if not using MQTT
override fun createIntent(context: Context): Intent {
return Intent(context, YourActivity::class.java)
}
}
```
### 3. Create a settings activity
```kotlin
// YourSettingsActivity.kt
package club.clubk.ktag.apps.yourname
import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity
class YourSettingsActivity : BaseSettingsActivity() {
companion object {
@JvmStatic
fun createIntent(context: Context): Intent {
return BaseSettingsActivity.createIntent(context, R.xml.settings_pref, YourSettingsActivity::class.java)
}
}
}
```
### 4. Create the preferences XML
Create `src/main/res/xml/settings_pref.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory android:title="Your Settings">
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Hint text when empty"
android:inputType="text"
android:key="your_setting_key"
android:summary="%s"
android:title="Setting Title" />
</PreferenceCategory>
</PreferenceScreen>
```
### 5. Declare the settings activity in `AndroidManifest.xml`
```xml
<activity
android:name=".YourSettingsActivity"
android:exported="false"
android:label="Settings"
android:parentActivityName=".YourActivity"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".YourActivity"/>
</activity>
```
### 6. Launch settings from your activity
```java
// In Java:
Intent intent = YourSettingsActivity.createIntent(this);
startActivity(intent);
```
```kotlin
// In Kotlin:
startActivity(YourSettingsActivity.createIntent(this))
```
### Using MQTT
MQTT connection settings (server URI, username, password, battlefield) are configured centrally in the "MQTT Settings" page accessible from the main launcher's overflow menu. Subapps do **not** need their own MQTT settings UI.
A single `SharedMqttClient` singleton (in `shared-services`) manages the MQTT connection for the entire app. It connects at app startup and reconnects automatically when settings change. Subapps publish and subscribe through it:
```kotlin
import club.clubk.ktag.apps.sharedservices.SharedMqttClient
import club.clubk.ktag.apps.sharedservices.MqttMessageListener
// Publish a message
val battlefield = SharedMqttClient.battlefield
SharedMqttClient.publish("KTag/$battlefield/YourApp/Hello", "Hello!".toByteArray(), qos = 1, retained = false)
// Subscribe to a topic
val listener = object : MqttMessageListener {
override fun onMessageReceived(topic: String, payload: ByteArray) {
val text = String(payload)
// handle message
}
}
SharedMqttClient.subscribe("KTag/$battlefield/YourApp/Listen", listener)
// Unsubscribe when done (e.g. in cleanup/onCleared)
SharedMqttClient.unsubscribe("KTag/$battlefield/YourApp/Listen", listener)
```
### GPS Location Publishing
Once connected to an MQTT broker, the app automatically publishes the device's GPS position every **30 seconds** (subject to OS scheduling) to:
```
KTag/{Battlefield}/Devices/{Device ID}/Location
```
The payload is a JSON object:
```json
{"lat":51.5074,"lon":-0.1278,"alt":12.3,"accuracy":5.0}
```
`alt` and `accuracy` are omitted if the device does not provide them. The message is published with QoS 1 and `retained=true`, so a subscriber joining mid-game will immediately receive the last known position. Location updates stop automatically when the MQTT connection is lost.
The MQTT Settings page includes a **Device ID** field (defaults to the device's model name) used as the MQTT client identifier. When **Autodiscovery** is enabled, the client automatically finds a KTag broker on the local network via mDNS (`_mqtt._tcp.` with `purpose=KTag MQTT Broker`) and connects using the credentials `KTag` / `{Battlefield}`. The broker automatically ensures this credential exists on startup.
## 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.
🛡 <https://ktag.clubk.club> 🃞
Copyright © 2025-2026 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 <http://www.gnu.org/licenses/>.
## Open-Source Software
This software in turn makes use of the following open-source software libraries and components:
| Name | Version | License ([SPDX](https://spdx.org/licenses/)) | URL
|------------------------|--------------:|---------------------------------------------------------|---------------------------------------------
| Kotlin | 2.2.20 | [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) | https://kotlinlang.org/
| JetBrains Mono | 2.304 | [OFL-1.1](https://spdx.org/licenses/OFL-1.1.html) | https://github.com/JetBrains/JetBrainsMono
| Moquette MQTT Broker | 0.17 | [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) | https://github.com/moquette-io/moquette
| Eclipse Paho MQTT | 1.2.4 + 1.1.1 | [EPL-2.0](https://spdx.org/licenses/EPL-2.0.html) | https://github.com/eclipse/paho.mqtt.java
| usb-serial-for-android | 3.10.0 | [MIT](https://spdx.org/licenses/MIT.html) | https://github.com/mik3y/usb-serial-for-android/
| CameraX | 1.4.0 | [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) | https://developer.android.com/jetpack/androidx/releases/camera
| MediaPipe Tasks | 0.10.32 | [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) | https://developers.google.com/mediapipe
| EfficientDet-Lite0 | 1 | [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) | https://tfhub.dev/tensorflow/lite-model/efficientdet/lite0/detection/metadata/1
A collection of KTag applications and tools in a single Android app.

85
app/build.gradle.kts Normal file
View file

@ -0,0 +1,85 @@
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps"
compileSdk = 36
defaultConfig {
applicationId = "club.clubk.ktag.apps"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
val gitHashProvider = providers.exec {
commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText
val gitHash = gitHashProvider.get().trim()
val buildTime = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date())
buildConfigField("String", "GIT_HASH", "\"$gitHash\"")
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
buildConfig = true
compose = true
}
packaging {
resources {
excludes += listOf(
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties",
"META-INF/DEPENDENCIES"
)
}
}
}
dependencies {
implementation(project(":core"))
implementation(project(":subapp-sample"))
implementation(project(":subapp-bletool"))
implementation(project(":subapp-koth"))
implementation(project(":subapp-medic"))
implementation(project(":subapp-terminal"))
implementation(project(":subapp-mine"))
implementation(project(":subapp-konfigurator"))
implementation(project(":subapp-sentry"))
implementation(project(":mqtt-broker"))
implementation(project(":shared-services"))
implementation("androidx.appcompat:appcompat:1.7.0")
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.ui.graphics)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
}

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.KTagApps">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".BleSettingsActivity"
android:exported="false"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,14 @@
package club.clubk.ktag.apps
import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity
class BleSettingsActivity : BaseSettingsActivity() {
companion object {
fun createIntent(context: Context): Intent {
return BaseSettingsActivity.createIntent(context, R.xml.ble_settings_pref, BleSettingsActivity::class.java)
}
}
}

View file

@ -0,0 +1,303 @@
package club.clubk.ktag.apps
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Row
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import club.clubk.ktag.apps.core.SubApp
import club.clubk.ktag.apps.core.SubAppRegistry
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
import club.clubk.ktag.apps.core.ui.theme.KTagRed
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
import club.clubk.ktag.apps.mqttbroker.BrokerSettingsActivity
import club.clubk.ktag.apps.mqttbroker.BrokerStatus
import club.clubk.ktag.apps.mqttbroker.MqttBrokerManager
import club.clubk.ktag.apps.sharedservices.LocationPublisher
import club.clubk.ktag.apps.sharedservices.MqttClientStatus
import club.clubk.ktag.apps.sharedservices.SharedMqttClient
import club.clubk.ktag.apps.ui.theme.KTagAppsTheme
class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { startMqttServices() }
override fun onDestroy() {
super.onDestroy()
LocationPublisher.stop()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
KTagAppsTheme {
LauncherScreen()
}
}
val permissionsToRequest = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
if (permissionsToRequest.isNotEmpty()) {
permissionLauncher.launch(permissionsToRequest.toTypedArray())
} else {
startMqttServices()
}
}
private fun startMqttServices() {
MqttBrokerManager.startBrokerIfEnabled(this)
SharedMqttClient.connect(this)
LocationPublisher.start(this)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LauncherScreen() {
val subApps = SubAppRegistry.getAll()
val context = LocalContext.current
var menuExpanded by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) }
val brokerStatus by MqttBrokerManager.status.collectAsState()
val brokerEnabled = remember { MqttBrokerManager.isBrokerEnabled(context) }
val clientStatus by SharedMqttClient.status.collectAsState()
val deviceId = SharedMqttClient.currentDeviceId
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("KTag Apps") },
actions = {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text("MQTT Settings") },
onClick = {
menuExpanded = false
context.startActivity(BrokerSettingsActivity.createIntent(context))
}
)
DropdownMenuItem(
text = { Text("BLE Settings") },
onClick = {
menuExpanded = false
context.startActivity(BleSettingsActivity.createIntent(context))
}
)
DropdownMenuItem(
text = { Text("About") },
onClick = {
menuExpanded = false
showAboutDialog = true
}
)
}
}
)
if (brokerEnabled) {
BrokerStatusBar(brokerStatus)
}
ClientStatusBar(clientStatus, deviceId)
}
}
) { padding ->
if (showAboutDialog) {
AboutDialog(onDismiss = { showAboutDialog = false })
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 100.dp),
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(subApps) { subApp ->
SubAppItem(subApp)
}
}
}
}
@Composable
fun AboutDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("KTag Apps") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
Text("Commit ${BuildConfig.GIT_HASH}")
Text("Built ${BuildConfig.BUILD_TIME}")
Text("Type ${BuildConfig.BUILD_TYPE}")
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
}
)
}
@Composable
fun BrokerStatusBar(status: BrokerStatus) {
val context = LocalContext.current
val (dotColor, statusText) = when (status) {
is BrokerStatus.Starting -> KTagYellow to "MQTT Broker starting\u2026"
is BrokerStatus.Running -> {
val ip = MqttBrokerManager.getDeviceIpAddress(context) ?: "no network"
val ports = if (status.sslPort != null) {
"$ip:${status.port} / SSL :${status.sslPort}"
} else {
"$ip:${status.port}"
}
KTagGreen to "MQTT Broker running \u00b7 $ports"
}
is BrokerStatus.Error -> KTagRed to "MQTT Broker error: ${status.message}"
is BrokerStatus.Stopped -> KTagRed to "MQTT Broker stopped"
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor)
)
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun ClientStatusBar(status: MqttClientStatus, deviceId: String) {
val (dotColor, statusText) = when (status) {
is MqttClientStatus.Connecting -> KTagYellow to "MQTT Client connecting\u2026"
is MqttClientStatus.Connected -> KTagGreen to "MQTT Client connected as $deviceId"
is MqttClientStatus.Error -> KTagRed to "MQTT Client error: ${status.message}"
is MqttClientStatus.Disconnected -> KTagRed to "MQTT Client disconnected"
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(dotColor)
)
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun SubAppItem(subApp: SubApp) {
val context = LocalContext.current
Column(
modifier = Modifier
.clickable { context.startActivity(subApp.createIntent(context)) }
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(id = subApp.icon),
contentDescription = subApp.name,
modifier = Modifier.size(48.dp)
)
Text(
text = subApp.name,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp)
)
}
}

View file

@ -0,0 +1,18 @@
@file:Suppress("unused")
package club.clubk.ktag.apps.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
// App color aliases for clarity in module-specific code
val AppGreen = KTagGreen
val AppBlue = KTagBlue
val AppRed = KTagRed
val AppYellow = KTagYellow
val AppPurple = KTagPurple
val AppDarkGray = KTagDarkGray

View file

@ -0,0 +1,52 @@
package club.clubk.ktag.apps.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 KTagAppsTheme(
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,
content = content
)
}

View file

@ -0,0 +1,47 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1700.8"
android:viewportHeight="1700.8">
<group android:scaleX="0.5"
android:scaleY="0.5"
android:translateX="425.2"
android:translateY="425.2">
<path
android:pathData="m852.8,90.8h5.6l24,12.1q24,12.1 31.7,15.8 7.7,3.7 52.6,24.4 44.8,20.6 46.3,20.8 1.5,0.2 23.8,9.8 22.3,9.6 34.3,14.2 12,4.6 34.8,12.5 22.7,7.9 28.7,10 6,2.1 15,5 9,2.9 23.2,7.1 14.2,4.2 36,10 21.9,5.8 42.5,10.4 20.6,4.6 26.6,5.8 6,1.2 38.2,7.1 32.2,5.8 40.3,7.1 8.1,1.3 39.9,6.2 31.8,5 32.2,5 0.4,0 30.9,5.8 30.5,5.8 30.7,6l0.2,0.2 -1.3,62.1q-1.3,62.1 -6.4,160.4 -5.2,98.3 -8.1,137.5 -3,39.2 -6.9,67.1 -3.9,27.9 -9,52.9 -5.1,25 -11.2,50.4 -6,25.4 -10.7,44.6 -4.7,19.2 -11.2,41.2 -6.4,22.1 -7.7,26.7 -1.3,4.6 -3.9,11.7 -2.6,7.1 -6,17.5 -3.4,10.4 -10.7,30 -7.3,19.6 -12,31.2 -4.7,11.7 -11.6,27.9 -6.9,16.3 -17.6,39.2 -10.7,22.9 -12.9,26.7 -2.1,3.8 -15.7,27.3 -13.5,23.5 -16.3,27.9 -2.8,4.4 -13.3,22.1 -10.5,17.7 -13.7,22.5 -3.2,4.8 -9.9,15 -6.7,10.2 -10.7,15.8 -4.1,5.6 -9.4,13.3 -5.4,7.7 -10.3,14.2 -4.9,6.5 -7.7,10.8 -2.8,4.4 -8.1,11.3 -5.4,6.9 -10.3,13.7 -4.9,6.9 -10.7,14.2 -5.8,7.3 -9.9,12.5 -4.1,5.2 -17.4,19.8 -13.3,14.6 -31.3,33.3 -18,18.7 -31.7,32.1 -13.7,13.3 -18.2,16.9 -4.5,3.5 -21,17.9 -16.5,14.4 -20.2,17.5 -3.6,3.1 -18,15.4 -14.4,12.3 -18.9,15.8 -4.5,3.5 -10.3,8.3 -5.8,4.8 -16.1,13.1 -10.3,8.3 -15.7,12.3 -5.4,4 -10.3,7.1 -4.9,3.1 -18,10.8 -13.1,7.7 -17.2,10.4 -4.1,2.7 -9.2,5.6 -5.1,2.9 -10.7,6.7 -5.6,3.8 -8.2,5.4 -2.6,1.7 -11.2,5.8 -8.6,4.2 -20.6,9.6 -12,5.4 -16.7,7.1l-4.7,1.7 -5.1,-1.3q-5.1,-1.3 -11.2,-4.2 -6,-2.9 -20.2,-10 -14.2,-7.1 -16.3,-8.8 -2.1,-1.7 -10.9,-7.3 -8.8,-5.6 -13.3,-8.3 -4.5,-2.7 -17.6,-10.4 -13.1,-7.7 -18,-10.8 -4.9,-3.1 -10.3,-7.1 -5.4,-4 -12.2,-9.4 -6.9,-5.4 -18.2,-14.8 -11.4,-9.4 -19.7,-16.2 -8.4,-6.9 -24.5,-20.8 -16.1,-13.9 -20.6,-17.5 -4.5,-3.5 -19.7,-16.7 -15.2,-13.1 -16.9,-14.8 -1.7,-1.7 -15.2,-14.8 -13.5,-13.1 -18.9,-18.7 -5.4,-5.6 -23.6,-25 -18.2,-19.4 -21.9,-23.7 -3.6,-4.4 -10.7,-12.9 -7.1,-8.5 -11.2,-13.8 -4.1,-5.2 -8.2,-10.8 -4.1,-5.6 -8.6,-11.7 -4.5,-6 -8.2,-10.8 -3.6,-4.8 -6.2,-8.5 -2.6,-3.8 -7.1,-9.8 -4.5,-6 -7.7,-10.8 -3.2,-4.8 -8.6,-12.5 -5.4,-7.7 -8.2,-12.1 -2.8,-4.4 -9,-13.8 -6.2,-9.4 -8.2,-12.9 -1.9,-3.5 -10.7,-17.9 -8.8,-14.4 -11.6,-18.7 -2.8,-4.4 -13.9,-24 -11.2,-19.6 -16.7,-29.6 -5.6,-10 -9.9,-18.7 -4.3,-8.8 -12,-26.2 -7.7,-17.5 -7.7,-17.9 0,-0.4 -7.7,-19.2 -7.7,-18.7 -12,-29.6 -4.3,-10.8 -12,-32.9 -7.7,-22.1 -15.4,-47.1 -7.7,-25 -17.2,-60.8 -9.4,-35.8 -13.3,-53.3 -3.9,-17.5 -10.7,-50.8 -6.9,-33.3 -8.2,-43.7 -1.3,-10.4 -5.6,-63.7 -4.3,-53.3 -5.6,-80.4 -1.3,-27.1 -3,-57.5 -1.7,-30.4 -4.7,-99.5 -3,-69.1 -3,-97.9v-28.7l2.8,-1.5q2.8,-1.5 15.2,-3.5 12.4,-2.1 34.3,-5.4 21.9,-3.3 66.5,-10.8 44.6,-7.5 63.9,-10.8 19.3,-3.3 51.1,-10.4 31.7,-7.1 54.9,-13.3 23.2,-6.2 39.9,-11.7 16.7,-5.4 26.2,-8.3 9.4,-2.9 18,-5.8 8.6,-2.9 34.3,-12.5 25.7,-9.6 48.3,-19.4 22.5,-9.8 24,-10 1.5,-0.2 21.7,-9.4 20.2,-9.2 36.5,-16.7 16.3,-7.5 32.2,-15 15.9,-7.5 39.5,-19.6l23.6,-12.1z"
android:strokeWidth="2"
android:fillColor="#FFC857"
android:strokeColor="#323031"
android:fillType="evenOdd"/>
<path
android:pathData="m831.2,104.9h5.6l24,12.1q24,12.1 31.7,15.8 7.7,3.7 52.6,24.4 44.8,20.6 46.3,20.8 1.5,0.2 23.8,9.8 22.3,9.6 34.3,14.2 12,4.6 34.7,12.5 22.7,7.9 28.8,10 6,2.1 15,5 9,2.9 23.2,7.1 14.2,4.2 36,10 21.9,5.8 42.5,10.4 20.6,4.6 26.6,5.8 6,1.2 38.2,7.1 32.2,5.8 40.3,7.1 8.1,1.2 39.9,6.2 31.8,5 32.2,5 0.4,0 30.9,5.8 30.5,5.8 30.7,6l0.2,0.2 -1.3,62.1q-1.3,62.1 -6.4,160.4 -5.2,98.3 -8.1,137.5 -3,39.2 -6.9,67.1 -3.9,27.9 -9,52.9 -5.2,25 -11.2,50.4 -6,25.4 -10.7,44.6 -4.7,19.2 -11.2,41.2 -6.4,22.1 -7.7,26.7 -1.3,4.6 -3.9,11.7 -2.6,7.1 -6,17.5 -3.4,10.4 -10.7,30 -7.3,19.6 -12,31.2 -4.7,11.7 -11.6,27.9 -6.9,16.2 -17.6,39.1 -10.7,22.9 -12.9,26.7 -2.1,3.8 -15.7,27.3 -13.5,23.5 -16.3,27.9 -2.8,4.4 -13.3,22.1 -10.5,17.7 -13.7,22.5 -3.2,4.8 -9.9,15 -6.7,10.2 -10.7,15.8 -4.1,5.6 -9.4,13.3 -5.4,7.7 -10.3,14.2 -4.9,6.5 -7.7,10.8 -2.8,4.4 -8.1,11.2 -5.4,6.9 -10.3,13.8 -4.9,6.9 -10.7,14.2 -5.8,7.3 -9.9,12.5 -4.1,5.2 -17.4,19.8 -13.3,14.6 -31.3,33.3 -18,18.8 -31.7,32.1 -13.7,13.3 -18.2,16.9 -4.5,3.5 -21,17.9 -16.5,14.4 -20.2,17.5 -3.6,3.1 -18,15.4 -14.4,12.3 -18.9,15.8 -4.5,3.5 -10.3,8.3 -5.8,4.8 -16.1,13.1 -10.3,8.3 -15.7,12.3 -5.4,4 -10.3,7.1 -4.9,3.1 -18,10.8 -13.1,7.7 -17.2,10.4 -4.1,2.7 -9.2,5.6 -5.1,2.9 -10.7,6.7 -5.6,3.7 -8.2,5.4 -2.6,1.7 -11.2,5.8 -8.6,4.2 -20.6,9.6 -12,5.4 -16.7,7.1l-4.7,1.7 -5.1,-1.3q-5.1,-1.3 -11.2,-4.2 -6,-2.9 -20.2,-10 -14.2,-7.1 -16.3,-8.8 -2.1,-1.7 -10.9,-7.3 -8.8,-5.6 -13.3,-8.3 -4.5,-2.7 -17.6,-10.4 -13.1,-7.7 -18,-10.8 -4.9,-3.1 -10.3,-7.1 -5.4,-4 -12.2,-9.4 -6.9,-5.4 -18.2,-14.8 -11.4,-9.4 -19.7,-16.2 -8.4,-6.9 -24.5,-20.8 -16.1,-13.9 -20.6,-17.5 -4.5,-3.5 -19.7,-16.7 -15.2,-13.1 -16.9,-14.8 -1.7,-1.7 -15.2,-14.8 -13.5,-13.1 -18.9,-18.7 -5.4,-5.6 -23.6,-25 -18.2,-19.4 -21.9,-23.7 -3.6,-4.4 -10.7,-12.9 -7.1,-8.5 -11.2,-13.7 -4.1,-5.2 -8.2,-10.8 -4.1,-5.6 -8.6,-11.7 -4.5,-6 -8.2,-10.8 -3.6,-4.8 -6.2,-8.5 -2.6,-3.8 -7.1,-9.8 -4.5,-6 -7.7,-10.8 -3.2,-4.8 -8.6,-12.5 -5.4,-7.7 -8.2,-12.1 -2.8,-4.4 -9,-13.8 -6.2,-9.4 -8.2,-12.9 -1.9,-3.5 -10.7,-17.9 -8.8,-14.4 -11.6,-18.7 -2.8,-4.4 -13.9,-24 -11.2,-19.6 -16.7,-29.6 -5.6,-10 -9.9,-18.7 -4.3,-8.8 -12,-26.2 -7.7,-17.5 -7.7,-17.9 0,-0.4 -7.7,-19.2 -7.7,-18.7 -12,-29.6 -4.3,-10.8 -12,-32.9 -7.7,-22.1 -15.4,-47.1 -7.7,-25 -17.2,-60.8 -9.4,-35.8 -13.3,-53.3 -3.9,-17.5 -10.7,-50.8 -6.9,-33.3 -8.2,-43.7 -1.3,-10.4 -5.6,-63.7 -4.3,-53.3 -5.6,-80.4 -1.3,-27.1 -3,-57.5 -1.7,-30.4 -4.7,-99.5 -3,-69.1 -3,-97.9L192.8,301.1l2.8,-1.5q2.8,-1.5 15.2,-3.5 12.4,-2.1 34.3,-5.4 21.9,-3.3 66.5,-10.8 44.6,-7.5 63.9,-10.8 19.3,-3.3 51.1,-10.4 31.7,-7.1 54.9,-13.3 23.2,-6.2 39.9,-11.7 16.7,-5.4 26.2,-8.3 9.4,-2.9 18,-5.8 8.6,-2.9 34.3,-12.5 25.7,-9.6 48.3,-19.4 22.5,-9.8 24,-10 1.5,-0.2 21.7,-9.4 20.2,-9.2 36.5,-16.7 16.3,-7.5 32.2,-15 15.9,-7.5 39.5,-19.6l23.6,-12.1z"
android:strokeWidth="2"
android:fillColor="#FFC857"
android:strokeColor="#323031"
android:fillType="evenOdd"/>
<path
android:pathData="m850.9,32.6h6l25.9,13q25.9,13 34.3,17.1 8.3,4 56.7,26.3 48.4,22.3 50,22.5 1.6,0.2 25.7,10.6 24.1,10.3 37,15.3 13,4.9 37.5,13.5 24.5,8.5 31,10.8 6.5,2.2 16.2,5.4 9.7,3.1 25,7.6 15.3,4.5 38.9,10.8 23.6,6.3 45.8,11.2 22.2,4.9 28.7,6.3 6.5,1.3 41.2,7.6 34.7,6.3 43.5,7.6 8.8,1.3 43.1,6.7 34.3,5.4 34.7,5.4 0.5,0 33.3,6.3 32.9,6.3 33.1,6.5l0.2,0.2 -1.4,67q-1.4,67 -6.9,173.1 -5.6,106.1 -8.8,148.4 -3.2,42.3 -7.4,72.4 -4.2,30.1 -9.7,57.1 -5.6,27 -12,54.4 -6.5,27.4 -11.6,48.1 -5.1,20.7 -12,44.5 -6.9,23.8 -8.3,28.8 -1.4,4.9 -4.2,12.6 -2.8,7.6 -6.5,18.9 -3.7,11.2 -11.6,32.4 -7.9,21.1 -13,33.7 -5.1,12.6 -12.5,30.1 -7.4,17.5 -19,42.3 -11.6,24.7 -13.9,28.8 -2.3,4 -16.9,29.5 -14.6,25.4 -17.6,30.1 -3,4.7 -14.4,23.8 -11.3,19.1 -14.8,24.3 -3.5,5.2 -10.6,16.2 -7.2,11 -11.6,17.1 -4.4,6.1 -10.2,14.4 -5.8,8.3 -11.1,15.3 -5.3,7 -8.3,11.7 -3,4.7 -8.8,12.1 -5.8,7.4 -11.1,14.8 -5.3,7.4 -11.6,15.3 -6.3,7.9 -10.6,13.5 -4.4,5.6 -18.8,21.4 -14.4,15.7 -33.8,36 -19.5,20.2 -34.3,34.6 -14.8,14.4 -19.7,18.2 -4.9,3.8 -22.7,19.3 -17.8,15.5 -21.8,18.9 -3.9,3.4 -19.5,16.6 -15.5,13.3 -20.4,17.1 -4.9,3.8 -11.1,9 -6.3,5.2 -17.4,14.2 -11.1,9 -16.9,13.3 -5.8,4.3 -11.1,7.6 -5.3,3.4 -19.4,11.7 -14.1,8.3 -18.5,11.2 -4.4,2.9 -10,6.1 -5.6,3.2 -11.6,7.2 -6,4.1 -8.8,5.8 -2.8,1.8 -12,6.3 -9.3,4.5 -22.2,10.3 -13,5.8 -18.1,7.7l-5.1,1.8 -5.6,-1.3q-5.6,-1.3 -12,-4.5 -6.5,-3.2 -21.8,-10.8 -15.3,-7.6 -17.6,-9.4 -2.3,-1.8 -11.8,-7.9 -9.5,-6.1 -14.4,-9 -4.9,-2.9 -19,-11.2 -14.1,-8.3 -19.4,-11.7 -5.3,-3.4 -11.1,-7.6 -5.8,-4.3 -13.2,-10.1 -7.4,-5.8 -19.7,-16 -12.3,-10.1 -21.3,-17.5 -9,-7.4 -26.4,-22.5 -17.4,-15.1 -22.2,-18.9 -4.9,-3.8 -21.3,-18 -16.4,-14.2 -18.3,-16 -1.9,-1.8 -16.4,-16 -14.6,-14.2 -20.4,-20.2 -5.8,-6.1 -25.5,-27 -19.7,-20.9 -23.6,-25.6 -3.9,-4.7 -11.6,-13.9 -7.6,-9.2 -12,-14.8 -4.4,-5.6 -8.8,-11.7 -4.4,-6.1 -9.3,-12.6 -4.9,-6.5 -8.8,-11.7 -3.9,-5.2 -6.7,-9.2 -2.8,-4.1 -7.6,-10.6 -4.9,-6.5 -8.3,-11.7 -3.5,-5.2 -9.3,-13.5 -5.8,-8.3 -8.8,-13 -3,-4.7 -9.7,-14.8 -6.7,-10.1 -8.8,-13.9 -2.1,-3.8 -11.6,-19.3 -9.5,-15.5 -12.5,-20.2 -3,-4.7 -15,-25.8 -12,-21.1 -18.1,-31.9 -6,-10.8 -10.6,-20.2 -4.6,-9.4 -13,-28.3 -8.3,-18.9 -8.3,-19.3 0,-0.4 -8.3,-20.7 -8.3,-20.2 -13,-31.9 -4.6,-11.7 -13,-35.5 -8.3,-23.8 -16.7,-50.8 -8.3,-27 -18.5,-65.6 -10.2,-38.7 -14.4,-57.5 -4.2,-18.9 -11.6,-54.8 -7.4,-36 -8.8,-47.2 -1.4,-11.2 -6,-68.8 -4.6,-57.5 -6,-86.8 -1.4,-29.2 -3.2,-62 -1.9,-32.8 -5.1,-107.5 -3.2,-74.6 -3.2,-105.7v-31l3,-1.6q3,-1.6 16.4,-3.8 13.4,-2.2 37,-5.8 23.6,-3.6 71.8,-11.7 48.2,-8.1 69,-11.7 20.8,-3.6 55.1,-11.2 34.3,-7.6 59.3,-14.4 25,-6.7 43.1,-12.6 18.1,-5.8 28.2,-9 10.2,-3.1 19.4,-6.3 9.3,-3.1 37,-13.5 27.8,-10.3 52.1,-20.9 24.3,-10.6 25.9,-10.8 1.6,-0.2 23.4,-10.1 21.8,-9.9 39.4,-18 17.6,-8.1 34.7,-16.2 17.1,-8.1 42.6,-21.1l25.5,-13z"
android:strokeWidth="2"
android:fillColor="#323031"
android:strokeColor="#323031"
android:fillType="evenOdd"/>
<path
android:pathData="m853.4,97.5h5.6l24,12.1q24,12.1 31.7,15.8 7.7,3.7 52.6,24.4 44.8,20.6 46.3,20.8 1.5,0.2 23.8,9.8 22.3,9.6 34.3,14.2 12,4.6 34.8,12.5 22.7,7.9 28.7,10 6,2.1 15,5 9,2.9 23.2,7.1 14.2,4.2 36,10 21.9,5.8 42.5,10.4 20.6,4.6 26.6,5.8 6,1.2 38.2,7.1 32.2,5.8 40.3,7.1 8.1,1.3 39.9,6.2 31.8,5 32.2,5 0.4,0 30.9,5.8 30.5,5.8 30.7,6l0.2,0.2 -1.3,62.1q-1.3,62.1 -6.4,160.4 -5.2,98.3 -8.1,137.5 -3,39.2 -6.9,67.1 -3.9,27.9 -9,52.9 -5.1,25 -11.2,50.4 -6,25.4 -10.7,44.6 -4.7,19.2 -11.2,41.2 -6.4,22.1 -7.7,26.7 -1.3,4.6 -3.9,11.7 -2.6,7.1 -6,17.5 -3.4,10.4 -10.7,30 -7.3,19.6 -12,31.2 -4.7,11.7 -11.6,27.9 -6.9,16.3 -17.6,39.2 -10.7,22.9 -12.9,26.6 -2.1,3.8 -15.7,27.3 -13.5,23.5 -16.3,27.9 -2.8,4.4 -13.3,22.1 -10.5,17.7 -13.7,22.5 -3.2,4.8 -9.9,15 -6.6,10.2 -10.7,15.8 -4.1,5.6 -9.4,13.3 -5.4,7.7 -10.3,14.2 -4.9,6.5 -7.7,10.8 -2.8,4.4 -8.1,11.3 -5.4,6.9 -10.3,13.7 -4.9,6.9 -10.7,14.2 -5.8,7.3 -9.9,12.5 -4.1,5.2 -17.4,19.8 -13.3,14.6 -31.3,33.3 -18,18.7 -31.7,32.1 -13.7,13.3 -18.2,16.9 -4.5,3.5 -21,17.9 -16.5,14.4 -20.2,17.5 -3.6,3.1 -18,15.4 -14.4,12.3 -18.9,15.8 -4.5,3.5 -10.3,8.3 -5.8,4.8 -16.1,13.1 -10.3,8.3 -15.7,12.3 -5.4,4 -10.3,7.1 -4.9,3.1 -18,10.8 -13.1,7.7 -17.2,10.4 -4.1,2.7 -9.2,5.6 -5.1,2.9 -10.7,6.7 -5.6,3.8 -8.2,5.4 -2.6,1.7 -11.2,5.8 -8.6,4.2 -20.6,9.6 -12,5.4 -16.7,7.1l-4.7,1.7 -5.1,-1.3q-5.1,-1.3 -11.2,-4.2 -6,-2.9 -20.2,-10 -14.2,-7.1 -16.3,-8.8 -2.1,-1.7 -10.9,-7.3 -8.8,-5.6 -13.3,-8.3 -4.5,-2.7 -17.6,-10.4 -13.1,-7.7 -18,-10.8 -4.9,-3.1 -10.3,-7.1 -5.4,-4 -12.2,-9.4 -6.9,-5.4 -18.2,-14.8 -11.4,-9.4 -19.7,-16.2 -8.4,-6.9 -24.5,-20.8 -16.1,-14 -20.6,-17.5 -4.5,-3.5 -19.7,-16.7 -15.2,-13.1 -16.9,-14.8 -1.7,-1.7 -15.2,-14.8 -13.5,-13.1 -18.9,-18.7 -5.4,-5.6 -23.6,-25 -18.2,-19.4 -21.9,-23.7 -3.6,-4.4 -10.7,-12.9 -7.1,-8.5 -11.2,-13.8 -4.1,-5.2 -8.2,-10.8 -4.1,-5.6 -8.6,-11.7 -4.5,-6 -8.2,-10.8 -3.6,-4.8 -6.2,-8.5 -2.6,-3.8 -7.1,-9.8 -4.5,-6 -7.7,-10.8 -3.2,-4.8 -8.6,-12.5 -5.4,-7.7 -8.2,-12.1 -2.8,-4.4 -9,-13.8 -6.2,-9.4 -8.2,-12.9 -1.9,-3.5 -10.7,-17.9 -8.8,-14.4 -11.6,-18.7 -2.8,-4.4 -13.9,-24 -11.2,-19.6 -16.7,-29.6 -5.6,-10 -9.9,-18.8 -4.3,-8.7 -12,-26.2 -7.7,-17.5 -7.7,-17.9 0,-0.4 -7.7,-19.2 -7.7,-18.7 -12,-29.6 -4.3,-10.8 -12,-32.9 -7.7,-22.1 -15.4,-47.1 -7.7,-25 -17.2,-60.8 -9.4,-35.8 -13.3,-53.3 -3.9,-17.5 -10.7,-50.8 -6.9,-33.3 -8.2,-43.7 -1.3,-10.4 -5.6,-63.7 -4.3,-53.3 -5.6,-80.4 -1.3,-27.1 -3,-57.5 -1.7,-30.4 -4.7,-99.5 -3,-69.1 -3,-97.9v-28.7l2.8,-1.5q2.8,-1.5 15.2,-3.5 12.4,-2.1 34.3,-5.4 21.9,-3.3 66.5,-10.8 44.6,-7.5 63.9,-10.8 19.3,-3.3 51.1,-10.4 31.7,-7.1 54.9,-13.3 23.2,-6.2 39.9,-11.7 16.7,-5.4 26.2,-8.3 9.4,-2.9 18,-5.8 8.6,-2.9 34.3,-12.5 25.7,-9.6 48.3,-19.4 22.5,-9.8 24,-10 1.5,-0.2 21.7,-9.4 20.2,-9.2 36.5,-16.7 16.3,-7.5 32.2,-15 15.9,-7.5 39.5,-19.6l23.6,-12.1z"
android:strokeWidth="2"
android:fillColor="#ffc857"
android:strokeColor="#323031"
android:fillType="evenOdd"/>
<path
android:pathData="M528.6,300.7L705.8,300.7L705.8,646.6L961.3,300.7L1167,300.7L836.1,749.5 1201,1248.4l-221.8,0L705.8,875.2L705.8,1248.4L528.6,1248.4Z"
android:fillColor="#323031"/>
<path
android:pathData="m555.2,1216.9l0,-101.9l23.6,-0l0,36.7l97.6,-0l0,28.5l-97.6,-0l0,36.7z"
android:fillColor="#ffc857"/>
<path
android:pathData="m654.3,1044.9l0,44.6l22.1,7l0,28.7l-121.2,-40.9l0,-34l121.2,-40.9l0,28.7zM631.8,1082.4l0,-30.3l-48.2,15.1z"
android:fillColor="#ffc857"/>
<path
android:pathData="m667.4,895.3q5.7,10.7 8.5,22.1 2.8,11.5 2.8,23.7 0,27.6 -16.9,43.8 -17,16.1 -45.9,16.1 -29.3,-0 -46.1,-16.4 -16.8,-16.4 -16.8,-45 0,-11 2.3,-21.1 2.3,-10.1 6.7,-19.1l25.1,-0q-5.8,9.3 -8.6,18.4 -2.8,9.1 -2.8,18.3 0,17 10.5,26.3 10.4,9.2 29.8,9.2 19.2,-0 29.7,-8.9 10.5,-8.9 10.5,-25.3 0,-4.4 -0.6,-8.2 -0.6,-3.9 -1.9,-6.9l-23.5,-0l0,17.4l-20.9,-0l0,-44.4z"
android:fillColor="#ffc857"/>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">KTag Apps</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KTagApps" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory android:title="Device Detection">
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Seconds before a device is removed from the list"
android:inputType="number"
android:key="device_ttl_s"
android:summary="%s seconds"
android:defaultValue="5"
android:title="Device Timeout" />
</PreferenceCategory>
</PreferenceScreen>

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

40
core/build.gradle.kts Normal file
View file

@ -0,0 +1,40 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.core"
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
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)
implementation("com.github.mik3y:usb-serial-for-android:3.10.0")
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View file

@ -0,0 +1,73 @@
package club.clubk.ktag.apps.core
import androidx.compose.ui.graphics.Color
/**
* Unified data class representing a KTag device.
* Superset of fields used by the subapps.
*/
data class DeviceModel(
val name: String,
val version: String = "",
val deviceType: String = "",
val id: Int = 0,
val image: Int = 0,
val bleAddress: String = "00:00:00:00:00:00",
val rssi: Int = -128,
val color: Color = Color.Companion.White,
val teamId: Byte = NO_TEAMS,
val health: Int = 0,
val inRange: Boolean = false,
val hasBeenTagged: Boolean = false,
val isTaggedOut: Boolean = false,
val timeToLiveMs: Int = DEFAULT_TTL_MS
) {
fun withResetTtl(ttlMs: Int = DEFAULT_TTL_MS): DeviceModel = copy(timeToLiveMs = ttlMs)
fun withDecrementedTtl(decrementMs: Int): DeviceModel =
copy(timeToLiveMs = timeToLiveMs - decrementMs)
fun withRssi(newRssi: Int, minRssi: Int): DeviceModel =
copy(rssi = newRssi, inRange = newRssi >= minRssi)
fun withTagged(): DeviceModel = copy(hasBeenTagged = true)
fun withTaggedOut(ttlMs: Int = TAGGED_DISPLAY_TTL_MS): DeviceModel = copy(isTaggedOut = true, timeToLiveMs = ttlMs)
fun withReset(): DeviceModel = copy(hasBeenTagged = false)
val isExpired: Boolean
get() = timeToLiveMs <= 0
companion object {
const val DEFAULT_TTL_MS = 5000
const val TAGGED_DISPLAY_TTL_MS = 5000
const val ALL_TEAMS: Byte = 0
const val RED_TEAM: Byte = 1
const val BLUE_TEAM: Byte = 2
const val NO_TEAMS: Byte = 3
fun getTeamCounts(contacts: List<DeviceModel>): TeamCounts {
var redCount = 0
var blueCount = 0
for (contact in contacts) {
when (contact.teamId) {
RED_TEAM -> redCount++
BLUE_TEAM -> blueCount++
}
}
return TeamCounts(redCount, blueCount)
}
}
}
/**
* Data class holding team member counts.
*/
data class TeamCounts(
val red: Int,
val blue: Int
)

View file

@ -0,0 +1,60 @@
package club.clubk.ktag.apps.core;
import java.util.regex.Pattern;
public class HexUtils {
public static byte[] hexStringToByteArray(String s) {
int len = s.length();
byte[] data = new byte[len/2];
for(int i = 0; i < len; i+=2){
data[i/2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i+1), 16));
}
return data;
}
public static byte[] hexStringToByteArray(String s, char delimiter) {
s = s.replaceAll(Pattern.quote(String.valueOf(delimiter)), "");
int len = s.length();
byte[] data = new byte[len/2];
for(int i = 0; i < len; i+=2){
data[i/2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i+1), 16));
}
return data;
}
final protected static char[] hexArray = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
public static String byteArrayToHexString(byte[] bytes) {
char[] hexChars = new char[bytes.length*2];
int v;
for(int j=0; j < bytes.length; j++) {
v = bytes[j] & 0xFF;
hexChars[j*2] = hexArray[v>>>4];
hexChars[j*2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
public static String byteArrayToHexString(byte[] bytes, char delimiter) {
char[] hexChars = new char[(bytes.length*3) - 1];
int v;
for(int j=0; j < bytes.length; j++) {
v = bytes[j] & 0xFF;
if (j > 0)
{
hexChars[j*3 - 1] = delimiter;
}
hexChars[j*3] = hexArray[v>>>4];
hexChars[j*3 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
}

View file

@ -0,0 +1,12 @@
package club.clubk.ktag.apps.core
import android.content.Context
import android.content.Intent
import androidx.annotation.DrawableRes
interface SubApp {
val id: String
val name: String
@get:DrawableRes val icon: Int
fun createIntent(context: Context): Intent
}

View file

@ -0,0 +1,11 @@
package club.clubk.ktag.apps.core
object SubAppRegistry {
private val subApps = mutableListOf<SubApp>()
fun register(subApp: SubApp) {
subApps.add(subApp)
}
fun getAll(): List<SubApp> = subApps.toList()
}

View file

@ -0,0 +1,241 @@
package club.clubk.ktag.apps.core
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager
import android.os.Build
import android.util.Log
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import com.hoho.android.usbserial.util.SerialInputOutputManager
import java.io.IOException
import java.util.concurrent.Executors
class UsbSerialManager(
private val context: Context,
private val listener: Listener,
private val actionUsbPermission: String = "club.clubk.ktag.apps.USB_PERMISSION"
) {
interface Listener {
fun onDataReceived(data: ByteArray)
fun onConnectionStateChanged(state: ConnectionState)
fun onError(message: String)
fun onDevicesChanged(devices: List<UsbSerialDriver>)
fun onAutoConnecting(driver: UsbSerialDriver)
}
enum class ConnectionState {
DISCONNECTED,
AWAITING_PERMISSION,
CONNECTING,
CONNECTED,
ERROR
}
private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
private var usbSerialPort: UsbSerialPort? = null
private var usbConnection: UsbDeviceConnection? = null
private var ioManager: SerialInputOutputManager? = null
private var pendingDriver: UsbSerialDriver? = null
private var autoConnectEnabled = true
private val serialListener = object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) {
listener.onDataReceived(data)
}
override fun onRunError(e: Exception) {
Log.e(TAG, "Serial run error", e)
disconnect()
listener.onError("Connection lost: ${e.message}")
}
}
private val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
actionUsbPermission -> {
synchronized(this) {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
}
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
device?.let {
pendingDriver?.let { driver ->
connectToDevice(driver)
}
}
} else {
listener.onConnectionStateChanged(ConnectionState.DISCONNECTED)
listener.onError("USB permission denied")
}
pendingDriver = null
}
}
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
val devices = refreshDevices()
// Auto-connect if enabled and not already connected
if (autoConnectEnabled && !isConnected() && devices.isNotEmpty()) {
val driver = devices.first()
Log.i(TAG, "Auto-connecting to ${driver.device.deviceName}")
listener.onAutoConnecting(driver)
connect(driver)
}
}
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
}
if (device != null && usbSerialPort?.device == device) {
disconnect()
listener.onError("Device disconnected")
}
refreshDevices()
}
}
}
}
fun register() {
val filter = IntentFilter().apply {
addAction(actionUsbPermission)
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(usbReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(usbReceiver, filter)
}
val devices = refreshDevices()
// Auto-connect if a device is already plugged in
if (autoConnectEnabled && devices.isNotEmpty()) {
val driver = devices.first()
Log.i(TAG, "Auto-connecting to already-attached device: ${driver.device.deviceName}")
listener.onAutoConnecting(driver)
connect(driver)
}
}
fun unregister() {
try {
context.unregisterReceiver(usbReceiver)
} catch (e: IllegalArgumentException) {
// Receiver not registered
}
disconnect()
}
fun getAvailableDevices(): List<UsbSerialDriver> {
return UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
}
fun refreshDevices(): List<UsbSerialDriver> {
val devices = getAvailableDevices()
listener.onDevicesChanged(devices)
return devices
}
fun connect(driver: UsbSerialDriver) {
if (!usbManager.hasPermission(driver.device)) {
pendingDriver = driver
listener.onConnectionStateChanged(ConnectionState.AWAITING_PERMISSION)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
val permissionIntent = PendingIntent.getBroadcast(
context, 0, Intent(actionUsbPermission), flags
)
usbManager.requestPermission(driver.device, permissionIntent)
} else {
connectToDevice(driver)
}
}
@SuppressLint("MissingPermission")
private fun connectToDevice(driver: UsbSerialDriver) {
listener.onConnectionStateChanged(ConnectionState.CONNECTING)
try {
val connection = usbManager.openDevice(driver.device)
if (connection == null) {
listener.onConnectionStateChanged(ConnectionState.ERROR)
listener.onError("Failed to open device")
return
}
val port = driver.ports[0]
port.open(connection)
port.setParameters(BAUD_RATE, DATA_BITS, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
usbConnection = connection
usbSerialPort = port
ioManager = SerialInputOutputManager(port, serialListener).apply {
start()
}
listener.onConnectionStateChanged(ConnectionState.CONNECTED)
Log.i(TAG, "Connected to ${driver.device.deviceName}")
} catch (e: IOException) {
Log.e(TAG, "Connection failed", e)
disconnect()
listener.onConnectionStateChanged(ConnectionState.ERROR)
listener.onError("Connection failed: ${e.message}")
}
}
fun disconnect() {
ioManager?.apply {
listener = null
stop()
}
ioManager = null
try {
usbSerialPort?.close()
} catch (e: IOException) {
Log.w(TAG, "Error closing port", e)
}
usbSerialPort = null
usbConnection?.close()
usbConnection = null
listener.onConnectionStateChanged(ConnectionState.DISCONNECTED)
}
fun write(data: ByteArray) {
usbSerialPort?.let { port ->
try {
port.write(data, WRITE_TIMEOUT_MS)
} catch (e: IOException) {
Log.e(TAG, "Write failed", e)
listener.onError("Write failed: ${e.message}")
}
}
}
fun isConnected(): Boolean = usbSerialPort != null
companion object {
private const val TAG = "UsbSerialManager"
private const val BAUD_RATE = 115200
private const val DATA_BITS = 8
private const val WRITE_TIMEOUT_MS = 1000
}
}

View file

@ -0,0 +1,612 @@
package club.clubk.ktag.apps.core.ble;
import android.bluetooth.le.ScanResult;
import static club.clubk.ktag.apps.core.HexUtils.hexStringToByteArray;
public class Packet {
// Packet Types
public static final byte PACKET_TYPE_UNKNOWN = 0;
public static final byte PACKET_TYPE_INSTIGATING_GAME = 1;
public static final byte PACKET_TYPE_EVENT = 2;
public static final byte PACKET_TYPE_TAG = 3;
public static final byte PACKET_TYPE_CONSOLE = 4;
public static final byte PACKET_TYPE_STATUS = 5;
public static final byte PACKET_TYPE_PARAMETERS = 6;
public static final byte PACKET_TYPE_HELLO = 7;
// Event IDs
public static final int EVENT_NO_EVENT = 0;
public static final int EVENT_CONFIGURE = 1;
public static final int EVENT_CONFIGURED = 2;
public static final int EVENT_WRAPUP_COMPLETE = 3;
public static final int EVENT_GAME_OVER = 4;
public static final int EVENT_QUIET = 5;
public static final int EVENT_UNQUIET = 6;
public static final int EVENT_FORCE_STATE = 7;
// Parameter subtypes
public static final int PARAMETER_SUBTYPE_REQUEST_CURRENT = 0x00;
public static final int PARAMETER_SUBTYPE_CURRENT_INFO = 0x01;
public static final int PARAMETER_SUBTYPE_REQUEST_CHANGE = 0x02;
public static final int PARAMETER_SUBTYPE_ACKNOWLEDGE_CHANGE = 0x03;
public static final int PARAMETER_SUBTYPE_ERROR_CHANGING = 0x04;
public static final int PARAMETER_SUBTYPE_ERROR_RESPONDING = 0xFF;
// Parameter keys
public static final int PARAMETER_KEY_NONE = 0;
public static final int PARAMETER_KEY_TEAM_ID = 1;
public static final int PARAMETER_KEY_PLAYER_ID = 2;
public static final int PARAMETER_KEY_GAME_LENGTH = 3;
public static final int PARAMETER_KEY_MAX_HEALTH = 4;
public static final int PARAMETER_KEY_SECONDARY_COLOR = 5;
public static final int PARAMETER_KEY_SPECIAL_WEAPONS_ON_REENTRY = 6;
public static final int PARAMETER_KEY_SHOTS_FIRED_THIS_GAME = 10001;
public static final int PARAMETER_KEY_TAGS_RECEIVED_THIS_GAME = 10002;
public static final int PARAMETER_KEY_TIMES_TAGGED_OUT_THIS_GAME = 10003;
// Device types
public static final int DEVICE_TYPE_LITTLE_BOY_BLUE = 0x0000;
public static final int DEVICE_TYPE_2020TPC = 0x0001;
public static final int DEVICE_TYPE_MOBILE_APP = 0x0002;
public static final int DEVICE_TYPE_32ESPECIAL = 0x0003;
public static final int DEVICE_TYPE_UNKNOWN = 0xFFFF;
public static String getDeviceTypeName(int deviceType) {
switch (deviceType) {
case DEVICE_TYPE_LITTLE_BOY_BLUE: return "Little Boy BLuE";
case DEVICE_TYPE_2020TPC: return "2020TPC";
case DEVICE_TYPE_MOBILE_APP: return "Mobile App";
case DEVICE_TYPE_32ESPECIAL: return "32ESPecial";
default: return "Unknown";
}
}
public static boolean IsKTagPacket(ScanResult packet) {
boolean result = false;
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
if (bytes != null) {
if (bytes.length > 4) {
if ((bytes[0] == 'K') && (bytes[1] == 'T') && (bytes[2] == 'a') && (bytes[3] == 'g')) {
result = true;
}
}
}
return result;
}
public static boolean IsKTagInstigatingGamePacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_INSTIGATING_GAME);
}
public static boolean IsKTagEventPacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_EVENT);
}
public static boolean IsKTagTagPacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_TAG);
}
public static boolean IsKTagConsolePacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_CONSOLE);
}
public static boolean IsKTagStatusPacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_STATUS);
}
public static boolean IsKTagParametersPacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_PARAMETERS);
}
public static boolean IsKTagHelloPacket(ScanResult packet) {
return IsKTagPacketOfType(packet, PACKET_TYPE_HELLO);
}
private static boolean IsKTagPacketOfType(ScanResult packet, byte packetType) {
boolean result = false;
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
if (bytes != null) {
if (bytes.length > 4) {
if ((bytes[0] == 'K') && (bytes[1] == 'T') && (bytes[2] == 'a') && (bytes[3] == 'g') && (bytes[4] == packetType)) {
result = true;
}
}
}
return result;
}
public static class InstigatingGame {
byte[] BD_ADDR;
int RSSI;
int event_number;
int game_length_ms;
int time_until_countdown_ms;
int random_time_after_countdown_ds;
byte[] unused;
static byte InstigatingGameEventNumber = 0;
public InstigatingGame(int game_length_ms, int time_until_countdown_ms)
{
this.game_length_ms = game_length_ms;
this.time_until_countdown_ms = time_until_countdown_ms;
this.random_time_after_countdown_ds = 0;
}
public InstigatingGame(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
game_length_ms = ((bytes[9] & 0xFF) << 24) | ((bytes[8] & 0xFF) << 16) | ((bytes[7] & 0xFF) << 8) | (bytes[6] & 0xFF);
time_until_countdown_ms = ((bytes[13] & 0xFF) << 24) | ((bytes[12] & 0xFF) << 16) | ((bytes[11] & 0xFF) << 8) | (bytes[10] & 0xFF);
random_time_after_countdown_ds = bytes[14] & 0xFF;
}
public byte[] getBD_ADDR() { return BD_ADDR; }
public int getGame_length_ms() { return game_length_ms; }
public int getTime_until_countdown_ms() { return time_until_countdown_ms; }
public byte[] GetBytes()
{
byte[] DataBytes = {(byte) 'K',
(byte) 'T',
(byte) 'a',
(byte) 'g',
(byte) PACKET_TYPE_INSTIGATING_GAME,
(byte) InstigatingGameEventNumber++,
(byte) ((game_length_ms >> 0) & 0xFF),
(byte) ((game_length_ms >> 8) & 0xFF),
(byte) ((game_length_ms >> 16) & 0xFF),
(byte) ((game_length_ms >> 24) & 0xFF),
(byte) ((time_until_countdown_ms >> 0) & 0xFF),
(byte) ((time_until_countdown_ms >> 8) & 0xFF),
(byte) ((time_until_countdown_ms >> 16) & 0xFF),
(byte) ((time_until_countdown_ms >> 24) & 0xFF),
(byte) random_time_after_countdown_ds,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF};
return DataBytes;
}
}
public static class Event {
byte[] BD_ADDR;
int RSSI;
int event_number;
byte[] target_BD_ADDR;
int event_ID;
int event_data;
static byte EventEventNumber = 0;
public Event(byte[] target_BD_ADDR, int event_ID, int event_data) {
this.target_BD_ADDR = new byte[6];
for (int i = 0; i < 6; i++) {
this.target_BD_ADDR[i] = target_BD_ADDR[5 - i];
}
this.event_ID = event_ID;
this.event_data = event_data;
}
public Event(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
target_BD_ADDR = new byte[6];
for (int i = 0; i < 6; i++) {
target_BD_ADDR[i] = bytes[6 + i];
}
event_ID = ((bytes[15] & 0xFF) << 24) | ((bytes[14] & 0xFF) << 16) | ((bytes[13] & 0xFF) << 8) | (bytes[12] & 0xFF);
event_data = ((bytes[19] & 0xFF) << 24) | ((bytes[18] & 0xFF) << 16) | ((bytes[17] & 0xFF) << 8) | (bytes[16] & 0xFF);
}
public byte[] GetBytes() {
byte[] DataBytes = {(byte) 'K',
(byte) 'T',
(byte) 'a',
(byte) 'g',
(byte) PACKET_TYPE_EVENT,
(byte) EventEventNumber++,
(byte) target_BD_ADDR[0],
(byte) target_BD_ADDR[1],
(byte) target_BD_ADDR[2],
(byte) target_BD_ADDR[3],
(byte) target_BD_ADDR[4],
(byte) target_BD_ADDR[5],
(byte) ((event_ID >> 0) & 0xFF),
(byte) ((event_ID >> 8) & 0xFF),
(byte) ((event_ID >> 16) & 0xFF),
(byte) ((event_ID >> 24) & 0xFF),
(byte) ((event_data >> 0) & 0xFF),
(byte) ((event_data >> 8) & 0xFF),
(byte) ((event_data >> 16) & 0xFF),
(byte) ((event_data >> 24) & 0xFF),
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF};
return DataBytes;
}
public byte[] getBD_ADDR() { return BD_ADDR; }
public byte[] getTarget_BD_ADDR() { return target_BD_ADDR; }
public int getEvent_ID() { return event_ID; }
public int getEvent_data() { return event_data; }
}
public static class Status {
byte[] BD_ADDR;
int RSSI;
int event_number;
int tx_power_level;
int protocol;
byte team_ID;
int player_ID;
int health;
int maximum_health;
int primary_color;
int secondary_color;
int SystemK_top_level_state;
byte[] unused;
public Status(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
tx_power_level = bytes[6];
protocol = bytes[7];
team_ID = bytes[8];
player_ID = bytes[9];
health = (bytes[11] << 8) | bytes[10];
maximum_health = (bytes[13] << 8) | bytes[12];
primary_color = (int) ((((long) (bytes[17] & 0xFF)) << 24) | ((bytes[16] & 0xFF) << 16) | ((bytes[15] & 0xFF) << 8) | (bytes[14] & 0xFF));
secondary_color = (int) ((((long) (bytes[21] & 0xFF)) << 24) | ((bytes[20] & 0xFF) << 16) | ((bytes[19] & 0xFF) << 8) | (bytes[18] & 0xFF));
SystemK_top_level_state = bytes[22] & 0xFF;
unused = new byte[4];
unused[0] = bytes[23];
unused[1] = bytes[24];
unused[2] = bytes[25];
unused[3] = bytes[26];
}
public byte[] getBD_ADDR() {
return BD_ADDR;
}
public int getHealth() {
return health;
}
public int getPrimary_color() {
return primary_color;
}
public byte getTeam_ID() { return team_ID; }
public int getMaximum_health() { return maximum_health; }
public int getSystemK_top_level_state() { return SystemK_top_level_state; }
}
public static class Tag {
byte[] BD_ADDR;
int RSSI;
int event_number;
int tx_power_level;
int protocol;
byte team_ID;
int player_ID;
int damage;
int color;
byte[] target_BD_ADDR;
byte[] unused;
static byte TagEventNumber = 0;
public Tag(byte[] target_BD_ADDR) {
this.target_BD_ADDR = new byte[6];
for(int i = 0; i < 6; i++)
{
this.target_BD_ADDR[i] = target_BD_ADDR[5-i];
//this.target_BD_ADDR[i] = (byte) 0xFF;
}
}
public Tag(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
tx_power_level = bytes[6];
protocol = bytes[7];
team_ID = bytes[8];
player_ID = bytes[9];
damage = ((bytes[11] & 0xFF) << 8) | (bytes[10] & 0xFF);
color = (int) ((((long) (bytes[15] & 0xFF)) << 24) | ((bytes[14] & 0xFF) << 16) | ((bytes[13] & 0xFF) << 8) | (bytes[12] & 0xFF));
target_BD_ADDR = new byte[6];
for (int i = 0; i < 6; i++) {
target_BD_ADDR[i] = bytes[16 + i];
}
}
public byte[] GetBytes()
{
byte[] DataBytes = {(byte) 'K',
(byte) 'T',
(byte) 'a',
(byte) 'g',
(byte) 0x03,
(byte) TagEventNumber++,
(byte) 4, // Tx Power Level (dBm)
(byte) 0x03, // Protocol
(byte) 0x00, // Team ID
(byte) 0xFF, // Player ID
(byte) 0x9C, // Damage (lsb)
(byte) 0xFF, // Damage (msb)
(byte) 0xFF, // Primary Color RED
(byte) 0xFF, // Primary Color GREEN
(byte) 0xFF, // Primary Color BLUE
(byte) 0xFE, // Primary Color BRIGHTNESS
(byte) target_BD_ADDR[0],
(byte) target_BD_ADDR[1],
(byte) target_BD_ADDR[2],
(byte) target_BD_ADDR[3],
(byte) target_BD_ADDR[4],
(byte) target_BD_ADDR[5],
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF,
(byte) 0xFF};
return DataBytes;
}
public byte[] getBD_ADDR() { return BD_ADDR; }
public byte getTeam_ID() { return team_ID; }
public int getPlayer_ID() { return player_ID; }
public int getDamage() { return damage; }
public int getColor() { return color; }
public byte[] getTarget_BD_ADDR() { return target_BD_ADDR; }
}
public static class Console {
byte[] BD_ADDR;
int RSSI;
int event_number;
byte[] console_data;
public Console(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
console_data = new byte[21];
System.arraycopy(bytes, 6, console_data, 0, 21);
}
public byte[] getBD_ADDR() { return BD_ADDR; }
public byte[] getConsole_data() { return console_data; }
}
public static class Parameters {
byte[] BD_ADDR;
int RSSI;
int event_number;
byte[] target_BD_ADDR;
int subtype;
int key1;
int value1;
int key2;
int value2;
static byte ParametersEventNumber = 0;
public Parameters(byte[] target_BD_ADDR, int subtype, int key1, int value1, int key2, int value2) {
this.target_BD_ADDR = new byte[6];
for (int i = 0; i < 6; i++) {
this.target_BD_ADDR[i] = target_BD_ADDR[5 - i];
}
this.subtype = subtype;
this.key1 = key1;
this.value1 = value1;
this.key2 = key2;
this.value2 = value2;
}
public Parameters(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
target_BD_ADDR = new byte[6];
for (int i = 0; i < 6; i++) {
target_BD_ADDR[i] = bytes[6 + i];
}
subtype = bytes[12] & 0xFF;
key1 = ((bytes[14] & 0xFF) << 8) | (bytes[13] & 0xFF);
value1 = ((bytes[18] & 0xFF) << 24) | ((bytes[17] & 0xFF) << 16) | ((bytes[16] & 0xFF) << 8) | (bytes[15] & 0xFF);
key2 = ((bytes[20] & 0xFF) << 8) | (bytes[19] & 0xFF);
value2 = ((bytes[24] & 0xFF) << 24) | ((bytes[23] & 0xFF) << 16) | ((bytes[22] & 0xFF) << 8) | (bytes[21] & 0xFF);
}
public byte[] GetBytes() {
byte[] DataBytes = {(byte) 'K',
(byte) 'T',
(byte) 'a',
(byte) 'g',
(byte) PACKET_TYPE_PARAMETERS,
(byte) ParametersEventNumber++,
(byte) target_BD_ADDR[0],
(byte) target_BD_ADDR[1],
(byte) target_BD_ADDR[2],
(byte) target_BD_ADDR[3],
(byte) target_BD_ADDR[4],
(byte) target_BD_ADDR[5],
(byte) subtype,
(byte) ((key1 >> 0) & 0xFF),
(byte) ((key1 >> 8) & 0xFF),
(byte) ((value1 >> 0) & 0xFF),
(byte) ((value1 >> 8) & 0xFF),
(byte) ((value1 >> 16) & 0xFF),
(byte) ((value1 >> 24) & 0xFF),
(byte) ((key2 >> 0) & 0xFF),
(byte) ((key2 >> 8) & 0xFF),
(byte) ((value2 >> 0) & 0xFF),
(byte) ((value2 >> 8) & 0xFF),
(byte) ((value2 >> 16) & 0xFF),
(byte) ((value2 >> 24) & 0xFF),
(byte) 0xFF,
(byte) 0xFF};
return DataBytes;
}
public byte[] getTarget_BD_ADDR() {
return target_BD_ADDR;
}
public int getSubtype() {
return subtype;
}
public int getKey1() {
return key1;
}
public int getValue1() {
return value1;
}
public int getKey2() {
return key2;
}
public int getValue2() {
return value2;
}
}
public static class Hello {
byte[] BD_ADDR;
int RSSI;
int event_number;
int SystemK_major_version;
int SystemK_minor_version;
int device_type;
byte team_ID;
String device_name;
static byte HelloEventNumber = 0;
public Hello(int major_version, int minor_version, int device_type, byte team_ID, String device_name) {
this.SystemK_major_version = major_version;
this.SystemK_minor_version = minor_version;
this.device_type = device_type;
this.team_ID = team_ID;
this.device_name = device_name;
}
public Hello(ScanResult packet) {
BD_ADDR = hexStringToByteArray(packet.getDevice().getAddress(), ':');
RSSI = packet.getRssi();
byte[] bytes = packet.getScanRecord().getManufacturerSpecificData(0xFFFF);
event_number = bytes[5];
SystemK_major_version = bytes[6] & 0xFF;
SystemK_minor_version = bytes[7] & 0xFF;
device_type = ((bytes[9] & 0xFF) << 8) | (bytes[8] & 0xFF);
team_ID = bytes[10];
// Extract device name (bytes 11-26, null-terminated string)
StringBuilder sb = new StringBuilder();
for (int i = 11; i < bytes.length && bytes[i] != 0; i++) {
sb.append((char) bytes[i]);
}
device_name = sb.toString();
}
public byte[] GetBytes() {
byte[] nameBytes = device_name.getBytes();
byte[] DataBytes = new byte[27];
DataBytes[0] = (byte) 'K';
DataBytes[1] = (byte) 'T';
DataBytes[2] = (byte) 'a';
DataBytes[3] = (byte) 'g';
DataBytes[4] = (byte) PACKET_TYPE_HELLO;
DataBytes[5] = (byte) HelloEventNumber++;
DataBytes[6] = (byte) SystemK_major_version;
DataBytes[7] = (byte) SystemK_minor_version;
DataBytes[8] = (byte) ((device_type >> 0) & 0xFF);
DataBytes[9] = (byte) ((device_type >> 8) & 0xFF);
DataBytes[10] = team_ID;
// Copy device name (up to 16 bytes)
int nameLen = Math.min(nameBytes.length, 16);
for (int i = 0; i < nameLen; i++) {
DataBytes[11 + i] = nameBytes[i];
}
// Fill remaining with 0xFF
for (int i = nameLen; i < 16; i++) {
DataBytes[11 + i] = (byte) 0xFF;
}
return DataBytes;
}
public byte[] getBD_ADDR() {
return BD_ADDR;
}
public int getSystemK_major_version() {
return SystemK_major_version;
}
public int getSystemK_minor_version() {
return SystemK_minor_version;
}
public int getDevice_type() {
return device_type;
}
public byte getTeam_ID() {
return team_ID;
}
public String getDevice_name() {
return device_name;
}
}
}

View file

@ -0,0 +1,42 @@
package club.clubk.ktag.apps.core.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import kotlin.math.max
import kotlin.math.min
/**
* Utility functions for color manipulation.
*/
object ColorUtils {
/**
* Washes out a color by reducing its saturation and optionally increasing brightness.
*/
fun washOutColor(color: Color, saturationFactor: Float, brightnessFactor: Float = 1.0f): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
// Reduce saturation
hsv[1] = max(0f, min(1f, hsv[1] * saturationFactor))
// Adjust brightness (Value)
hsv[2] = max(0f, min(1f, hsv[2] * brightnessFactor))
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* Creates a pastel version of the color.
*/
fun makePastel(color: Color): Color {
return washOutColor(color, 0.3f, 1.2f)
}
/**
* Converts an Android color int to Compose Color.
*/
fun fromAndroidColor(androidColor: Int): Color {
return Color(androidColor)
}
}

View file

@ -0,0 +1,66 @@
package club.clubk.ktag.apps.core.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RenameDeviceDialog(
macAddress: String,
currentName: String,
onDismiss: () -> Unit,
onSave: (String) -> Unit
) {
var nameText by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename Device") },
text = {
Column {
Text(
text = macAddress,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nameText,
onValueChange = { nameText = it },
label = { Text("Device Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
if (nameText.isNotBlank()) {
onSave(nameText.trim())
}
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,11 @@
package club.clubk.ktag.apps.core.ui.theme
import androidx.compose.ui.graphics.Color
// KTag brand colors
val KTagGreen = Color(0xFF4BA838)
val KTagBlue = Color(0xFF4D6CFA)
val KTagRed = Color(0xFFF34213)
val KTagYellow = Color(0xFFFFC857)
val KTagPurple = Color(0xFF9B59B6)
val KTagDarkGray = Color(0xFF323031)

View file

@ -0,0 +1,31 @@
package club.clubk.ktag.apps.core.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.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
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
)
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

8
gradle.properties Normal file
View file

@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false

View file

@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

44
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,44 @@
[versions]
agp = "9.0.1"
kotlin = "2.2.20"
core-ktx = "1.13.1"
activity-compose = "1.9.3"
compose-bom = "2024.12.01"
startup = "1.1.1"
lifecycle = "2.8.7"
appcompat = "1.7.0"
recyclerview = "1.3.2"
cardview = "1.0.0"
preference = "1.2.1"
constraintlayout = "2.2.1"
localbroadcastmanager = "1.1.0"
material = "1.12.0"
paho-mqtt-client = "1.2.4"
paho-mqtt-android = "1.1.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "startup" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
paho-mqtt-client = { group = "org.eclipse.paho", name = "org.eclipse.paho.client.mqttv3", version.ref = "paho-mqtt-client" }
paho-mqtt-android = { group = "org.eclipse.paho", name = "org.eclipse.paho.android.service", version.ref = "paho-mqtt-android" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View file

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View file

@ -0,0 +1,58 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.mqttbroker"
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += listOf(
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties",
"META-INF/DEPENDENCIES"
)
}
}
}
dependencies {
implementation(project(":core"))
implementation(project(":shared-services"))
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("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.preference:preference:1.2.1")
// Moquette MQTT Broker
implementation("io.moquette:moquette-broker:0.17")
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".MqttBrokerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".BrokerSettingsActivity"
android:exported="false"
android:parentActivityName="club.clubk.ktag.apps.MainActivity"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar" />
</application>
</manifest>

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.mqttbroker
object BrokerPreferenceKeys {
const val BROKER_ENABLED = "broker_enabled"
const val SSL_ENABLED = "broker_ssl_enabled"
const val AUTH_ENABLED = "broker_auth_enabled"
const val NSD_ENABLED = "broker_nsd_enabled"
const val PORT = "broker_port"
const val SSL_PORT = "broker_ssl_port"
const val DEFAULT_PORT = "1883"
const val DEFAULT_SSL_PORT = "8883"
}

View file

@ -0,0 +1,48 @@
package club.clubk.ktag.apps.mqttbroker
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import club.clubk.ktag.apps.sharedservices.SharedMqttClient
class BrokerSettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(club.clubk.ktag.apps.sharedservices.R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(club.clubk.ktag.apps.sharedservices.R.id.settings_container, BrokerSettingsFragment())
.commit()
}
}
override fun finish() {
if (MqttBrokerManager.isBrokerEnabled(this)) {
MqttBrokerManager.startBrokerIfEnabled(this)
} else {
MqttBrokerManager.stopBroker(this)
}
SharedMqttClient.connect(this)
super.finish()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
fun createIntent(context: Context): Intent {
return Intent(context, BrokerSettingsActivity::class.java)
}
}
}

View file

@ -0,0 +1,130 @@
package club.clubk.ktag.apps.mqttbroker
import android.os.Bundle
import android.text.InputType
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import club.clubk.ktag.apps.sharedservices.MqttPreferenceKeys
class BrokerSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.broker_settings_pref, rootKey)
findPreference<Preference>("broker_manage_users")?.setOnPreferenceClickListener {
showManageUsersDialog()
true
}
// Set up autodiscovery toggle to enable/disable manual connection fields
val autodiscoveryPref = findPreference<SwitchPreferenceCompat>(MqttPreferenceKeys.AUTODISCOVERY)
autodiscoveryPref?.setOnPreferenceChangeListener { _, newValue ->
updateManualFieldsEnabled(newValue as Boolean)
true
}
updateManualFieldsEnabled(autodiscoveryPref?.isChecked ?: false)
updateManageUsersSummary()
}
private fun updateManualFieldsEnabled(autodiscoveryEnabled: Boolean) {
val disabled = autodiscoveryEnabled
findPreference<Preference>(MqttPreferenceKeys.SERVER_URI)?.isEnabled = !disabled
findPreference<Preference>(MqttPreferenceKeys.USERNAME)?.isEnabled = !disabled
findPreference<Preference>(MqttPreferenceKeys.PASSWORD)?.isEnabled = !disabled
}
private fun updateManageUsersSummary() {
val count = CredentialManager.getCredentialCount(requireContext())
findPreference<Preference>("broker_manage_users")?.summary =
if (count == 0) "No users configured"
else "$count user(s) configured"
}
private fun showManageUsersDialog() {
val context = requireContext()
val credentials = CredentialManager.loadCredentials(context)
val usernames = credentials.keys.toList()
val builder = AlertDialog.Builder(context)
.setTitle("Manage Users")
.setPositiveButton("Add User") { _, _ -> showAddUserDialog() }
.setNegativeButton("Close", null)
if (usernames.isEmpty()) {
builder.setMessage("No users configured. Add a user to enable authentication.")
} else {
builder.setItems(usernames.toTypedArray()) { _, which ->
showRemoveUserDialog(usernames[which])
}
}
builder.show()
}
private fun showAddUserDialog() {
val context = requireContext()
val layout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, 0)
}
val usernameInput = EditText(context).apply {
hint = "Username"
inputType = InputType.TYPE_CLASS_TEXT
}
layout.addView(usernameInput)
val passwordInput = EditText(context).apply {
hint = "Password"
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
layout.addView(passwordInput)
AlertDialog.Builder(context)
.setTitle("Add User")
.setView(layout)
.setPositiveButton("Add") { _, _ ->
val username = usernameInput.text.toString().trim()
val password = passwordInput.text.toString()
when {
username.isEmpty() || password.isEmpty() -> {
Toast.makeText(context, "Username and password are required", Toast.LENGTH_SHORT).show()
}
username.contains(":") -> {
Toast.makeText(context, "Username cannot contain ':'", Toast.LENGTH_SHORT).show()
}
else -> {
CredentialManager.addCredential(context, username, password)
Toast.makeText(context, "User '$username' added", Toast.LENGTH_SHORT).show()
updateManageUsersSummary()
}
}
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showRemoveUserDialog(username: String) {
val context = requireContext()
AlertDialog.Builder(context)
.setTitle("Remove User")
.setMessage("Remove user '$username'?")
.setPositiveButton("Remove") { _, _ ->
CredentialManager.removeCredential(context, username)
Toast.makeText(context, "User '$username' removed", Toast.LENGTH_SHORT).show()
updateManageUsersSummary()
}
.setNegativeButton("Cancel", null)
.show()
}
}

View file

@ -0,0 +1,101 @@
package club.clubk.ktag.apps.mqttbroker;
import android.content.Context;
import android.util.Log;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedHashMap;
import java.util.Map;
public class CredentialManager {
private static final String TAG = "CredentialManager";
private static final String CREDENTIALS_FILE = "mqtt_credentials.txt";
public static Map<String, String> loadCredentials(Context context) {
Map<String, String> credentials = new LinkedHashMap<>();
File file = new File(context.getFilesDir(), CREDENTIALS_FILE);
if (!file.exists()) {
return credentials;
}
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
int sep = line.indexOf(':');
if (sep > 0 && sep < line.length() - 1) {
String username = line.substring(0, sep);
String hash = line.substring(sep + 1);
credentials.put(username, hash);
}
}
} catch (IOException e) {
Log.e(TAG, "Error loading credentials", e);
}
return credentials;
}
public static void saveCredentials(Context context, Map<String, String> credentials) {
File file = new File(context.getFilesDir(), CREDENTIALS_FILE);
try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) {
for (Map.Entry<String, String> entry : credentials.entrySet()) {
writer.write(entry.getKey() + ":" + entry.getValue());
writer.newLine();
}
} catch (IOException e) {
Log.e(TAG, "Error saving credentials", e);
}
}
public static void addCredential(Context context, String username, String password) {
Map<String, String> credentials = loadCredentials(context);
credentials.put(username, hashPassword(password));
saveCredentials(context, credentials);
}
public static void removeCredential(Context context, String username) {
Map<String, String> credentials = loadCredentials(context);
credentials.remove(username);
saveCredentials(context, credentials);
}
public static int getCredentialCount(Context context) {
return loadCredentials(context).size();
}
public static boolean checkCredential(String username, byte[] password, Map<String, String> credentials) {
if (username == null || password == null || credentials == null) {
return false;
}
String storedHash = credentials.get(username);
if (storedHash == null) {
return false;
}
String providedHash = hashPassword(new String(password, StandardCharsets.UTF_8));
return storedHash.equals(providedHash);
}
private static String hashPassword(String password) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
}

View file

@ -0,0 +1,21 @@
package club.clubk.ktag.apps.mqttbroker;
import android.content.Context;
import java.util.Map;
import io.moquette.broker.security.IAuthenticator;
public class FileAuthenticator implements IAuthenticator {
private final Context context;
public FileAuthenticator(Context context) {
this.context = context.getApplicationContext();
}
@Override
public boolean checkValid(String clientId, String username, byte[] password) {
Map<String, String> credentials = CredentialManager.loadCredentials(context);
return CredentialManager.checkCredential(username, password, credentials);
}
}

View file

@ -0,0 +1,63 @@
package club.clubk.ktag.apps.mqttbroker
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.text.format.Formatter
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed class BrokerStatus {
data object Stopped : BrokerStatus()
data object Starting : BrokerStatus()
data class Running(val port: Int, val sslPort: Int? = null) : BrokerStatus()
data class Error(val message: String) : BrokerStatus()
}
object MqttBrokerManager {
private val _status = MutableStateFlow<BrokerStatus>(BrokerStatus.Stopped)
val status: StateFlow<BrokerStatus> = _status.asStateFlow()
@JvmStatic
fun updateStatus(status: BrokerStatus) {
_status.value = status
}
fun isBrokerEnabled(context: Context): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return prefs.getBoolean(BrokerPreferenceKeys.BROKER_ENABLED, false)
}
fun startBroker(context: Context) {
val intent = Intent(context, MqttBrokerService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stopBroker(context: Context) {
val intent = Intent(context, MqttBrokerService::class.java)
context.stopService(intent)
}
fun startBrokerIfEnabled(context: Context) {
if (isBrokerEnabled(context)) {
startBroker(context)
}
}
@Suppress("DEPRECATION")
fun getDeviceIpAddress(context: Context): String? {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
?: return null
val ipInt = wifiManager.connectionInfo.ipAddress
if (ipInt == 0) return null
return Formatter.formatIpAddress(ipInt)
}
}

View file

@ -0,0 +1,326 @@
package club.clubk.ktag.apps.mqttbroker;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.widget.Toast;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.preference.PreferenceManager;
import club.clubk.ktag.apps.sharedservices.MqttPreferenceKeys;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import io.moquette.broker.Server;
import io.moquette.broker.config.MemoryConfig;
import io.moquette.interception.AbstractInterceptHandler;
import io.moquette.interception.InterceptHandler;
import io.moquette.interception.messages.InterceptConnectMessage;
import io.moquette.interception.messages.InterceptDisconnectMessage;
import io.moquette.interception.messages.InterceptPublishMessage;
import io.moquette.interception.messages.InterceptSubscribeMessage;
import io.moquette.interception.messages.InterceptUnsubscribeMessage;
public class MqttBrokerService extends Service {
private static final String TAG = "MqttBrokerService";
private static final String CHANNEL_ID = "MqttBrokerChannel";
private static final int NOTIFICATION_ID = 1;
private Server mqttBroker;
private int port = 1883;
private int sslPort = 8883;
private boolean sslEnabled = false;
private boolean authEnabled = false;
private boolean nsdEnabled = false;
private NsdManager nsdManager;
private NsdManager.RegistrationListener nsdRegistrationListener;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
Log.d(TAG, "Service created");
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
stopBroker();
loadConfiguration();
startForeground(NOTIFICATION_ID, createNotification("MQTT Broker is starting..."));
startBroker();
return START_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void loadConfiguration() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
sslEnabled = prefs.getBoolean(BrokerPreferenceKeys.SSL_ENABLED, false);
authEnabled = prefs.getBoolean(BrokerPreferenceKeys.AUTH_ENABLED, false);
nsdEnabled = prefs.getBoolean(BrokerPreferenceKeys.NSD_ENABLED, false);
port = Integer.parseInt(prefs.getString(BrokerPreferenceKeys.PORT, "1883"));
sslPort = Integer.parseInt(prefs.getString(BrokerPreferenceKeys.SSL_PORT, "8883"));
}
public boolean isBrokerRunning() {
return mqttBroker != null;
}
private void startBroker() {
MqttBrokerManager.updateStatus(BrokerStatus.Starting.INSTANCE);
new Thread(() -> {
try {
mqttBroker = new Server();
Properties configProps = new Properties();
configProps.setProperty("host", "0.0.0.0");
configProps.setProperty("port", String.valueOf(port));
configProps.setProperty("allow_anonymous", String.valueOf(!authEnabled));
configProps.setProperty("allow_zero_byte_client_id", "true");
configProps.setProperty("reauthorize_subscriptions_on_connect", "false");
// SSL Configuration
if (sslEnabled) {
if (!SSLCertificateManager.keystoreExists(getApplicationContext())) {
Log.d(TAG, "Keystore not found, generating self-signed certificate...");
SSLCertificateManager.generateSelfSignedCertificate(getApplicationContext());
}
configProps.setProperty("ssl_port", String.valueOf(sslPort));
configProps.setProperty("jks_path",
SSLCertificateManager.getKeystoreFile(getApplicationContext()).getAbsolutePath());
configProps.setProperty("key_store_type", "pkcs12");
configProps.setProperty("key_store_password", SSLCertificateManager.getKeystorePassword());
configProps.setProperty("key_manager_password", SSLCertificateManager.getKeystorePassword());
Log.d(TAG, "SSL enabled on port " + sslPort);
}
// Persistent data path
File dataDir = new File(getFilesDir(), "moquette_data");
if (!dataDir.exists()) {
dataDir.mkdirs();
}
configProps.setProperty("persistent_store", dataDir.getAbsolutePath());
MemoryConfig memoryConfig = new MemoryConfig(configProps);
List<InterceptHandler> handlers = new ArrayList<>();
handlers.add(new BrokerInterceptor());
// Ensure KTag autodiscovery credential exists
ensureKTagCredential();
if (authEnabled) {
FileAuthenticator authenticator = new FileAuthenticator(getApplicationContext());
mqttBroker.startServer(memoryConfig, handlers, null, authenticator, null);
} else {
mqttBroker.startServer(memoryConfig, handlers);
}
String statusMsg = "Broker running on port " + port;
if (sslEnabled) {
statusMsg += " (SSL: " + sslPort + ")";
}
if (authEnabled) {
statusMsg += " (Auth)";
}
if (nsdEnabled) {
statusMsg += " (mDNS)";
}
Log.d(TAG, "MQTT Broker started - " + statusMsg);
updateNotification(statusMsg);
MqttBrokerManager.updateStatus(new BrokerStatus.Running(port,
sslEnabled ? Integer.valueOf(sslPort) : null));
if (nsdEnabled) {
registerNsdService();
}
} catch (Exception e) {
Log.e(TAG, "Failed to start broker", e);
updateNotification("Failed to start: " + e.getMessage());
MqttBrokerManager.updateStatus(new BrokerStatus.Error(
e.getMessage() != null ? e.getMessage() : "Unknown error"));
}
}).start();
}
private void ensureKTagCredential() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String battlefield = prefs.getString(
MqttPreferenceKeys.BATTLEFIELD,
MqttPreferenceKeys.DEFAULT_BATTLEFIELD);
CredentialManager.addCredential(getApplicationContext(), "KTag", battlefield);
Log.d(TAG, "Ensured KTag credential with battlefield: " + battlefield);
}
private void stopBroker() {
unregisterNsdService();
if (mqttBroker != null) {
mqttBroker.stopServer();
mqttBroker = null;
}
MqttBrokerManager.updateStatus(BrokerStatus.Stopped.INSTANCE);
}
private void registerNsdService() {
NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName("MQTT Broker - KTag");
serviceInfo.setServiceType("_mqtt._tcp.");
serviceInfo.setPort(port);
serviceInfo.setAttribute("purpose", "KTag MQTT Broker");
serviceInfo.setAttribute("version", "1");
serviceInfo.setAttribute("ssl", String.valueOf(sslEnabled));
serviceInfo.setAttribute("auth", String.valueOf(authEnabled));
nsdManager = (NsdManager) getSystemService(Context.NSD_SERVICE);
nsdRegistrationListener = new NsdManager.RegistrationListener() {
@Override
public void onServiceRegistered(NsdServiceInfo info) {
Log.d(TAG, "NSD service registered: " + info.getServiceName());
}
@Override
public void onRegistrationFailed(NsdServiceInfo info, int errorCode) {
Log.e(TAG, "NSD registration failed: " + errorCode);
}
@Override
public void onServiceUnregistered(NsdServiceInfo info) {
Log.d(TAG, "NSD service unregistered");
}
@Override
public void onUnregistrationFailed(NsdServiceInfo info, int errorCode) {
Log.e(TAG, "NSD unregistration failed: " + errorCode);
}
};
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, nsdRegistrationListener);
}
private void unregisterNsdService() {
if (nsdManager != null && nsdRegistrationListener != null) {
try {
nsdManager.unregisterService(nsdRegistrationListener);
} catch (IllegalArgumentException e) {
Log.w(TAG, "NSD service already unregistered", e);
}
nsdRegistrationListener = null;
nsdManager = null;
}
}
@Override
public void onDestroy() {
super.onDestroy();
new Thread(this::stopBroker).start();
Log.d(TAG, "Service destroyed");
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"MQTT Broker Service",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("MQTT Broker background service");
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
}
private Notification createNotification(String contentText) {
Intent notificationIntent;
try {
notificationIntent = new Intent(this,
Class.forName("club.clubk.ktag.apps.MainActivity"));
} catch (ClassNotFoundException e) {
notificationIntent = new Intent();
}
PendingIntent pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("MQTT Broker")
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build();
}
private void updateNotification(String contentText) {
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.notify(NOTIFICATION_ID, createNotification(contentText));
}
}
private class BrokerInterceptor extends AbstractInterceptHandler {
@Override
public String getID() {
return "BrokerMonitor";
}
@Override
public void onConnect(InterceptConnectMessage msg) {
Log.d(TAG, "Client connected: " + msg.getClientID());
new Handler(Looper.getMainLooper()).post(() ->
Toast.makeText(MqttBrokerService.this,
"MQTT: " + msg.getClientID() + " connected",
Toast.LENGTH_SHORT).show());
}
@Override
public void onDisconnect(InterceptDisconnectMessage msg) {
Log.d(TAG, "Client disconnected: " + msg.getClientID());
}
@Override
public void onPublish(InterceptPublishMessage msg) {
Log.d(TAG, "Message published to " + msg.getTopicName() + " by " + msg.getClientID());
}
@Override
public void onSubscribe(InterceptSubscribeMessage msg) {
Log.d(TAG, msg.getClientID() + " subscribed to " + msg.getTopicFilter());
}
@Override
public void onUnsubscribe(InterceptUnsubscribeMessage msg) {
Log.d(TAG, msg.getClientID() + " unsubscribed from " + msg.getTopicFilter());
}
@Override
public void onSessionLoopError(Throwable ex) {
Log.e(TAG, "Session loop error", ex);
}
}
}

View file

@ -0,0 +1,317 @@
package club.clubk.ktag.apps.mqttbroker;
import android.content.Context;
import android.util.Log;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Date;
public class SSLCertificateManager {
private static final String TAG = "SSLCertManager";
private static final String KEYSTORE_FILE = "mqtt_broker.p12";
private static final String KEYSTORE_PASSWORD = "mqtt_broker_password";
private static final String KEY_ALIAS = "mqtt_broker_key";
public static File getKeystoreFile(Context context) {
return new File(context.getFilesDir(), KEYSTORE_FILE);
}
public static String getKeystorePassword() {
return KEYSTORE_PASSWORD;
}
public static String getKeyAlias() {
return KEY_ALIAS;
}
public static boolean keystoreExists(Context context) {
return getKeystoreFile(context).exists();
}
public static void generateSelfSignedCertificate(Context context) throws Exception {
Log.d(TAG, "Generating self-signed certificate...");
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048, new SecureRandom());
KeyPair keyPair = keyGen.generateKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 24 * 60 * 60 * 1000);
Date notAfter = new Date(now + 365L * 24 * 60 * 60 * 1000);
BigInteger serial = BigInteger.valueOf(now);
String dn = "CN=MQTT Broker, O=KTag Apps, C=US";
byte[] certDer = buildSelfSignedCertificateDer(
keyPair, privateKey, serial, notBefore, notAfter, dn);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(
new ByteArrayInputStream(certDer));
cert.verify(keyPair.getPublic());
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
Certificate[] chain = new Certificate[]{cert};
keyStore.setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD.toCharArray(), chain);
File keystoreFile = getKeystoreFile(context);
try (FileOutputStream fos = new FileOutputStream(keystoreFile)) {
keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray());
}
Log.d(TAG, "Self-signed certificate generated and saved to " + keystoreFile.getAbsolutePath());
}
public static KeyStore loadKeystore(Context context) throws Exception {
File keystoreFile = getKeystoreFile(context);
if (!keystoreFile.exists()) {
throw new Exception("Keystore file does not exist");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keystoreFile)) {
keyStore.load(fis, KEYSTORE_PASSWORD.toCharArray());
}
return keyStore;
}
public static void deleteKeystore(Context context) {
File keystoreFile = getKeystoreFile(context);
if (keystoreFile.exists()) {
if (keystoreFile.delete()) {
Log.d(TAG, "Keystore deleted");
} else {
Log.e(TAG, "Failed to delete keystore");
}
}
}
public static String getCertificateInfo(Context context) {
try {
KeyStore keyStore = loadKeystore(context);
X509Certificate cert = (X509Certificate) keyStore.getCertificate(KEY_ALIAS);
if (cert != null) {
StringBuilder info = new StringBuilder();
info.append("Subject: ").append(cert.getSubjectDN().getName()).append("\n");
info.append("Issuer: ").append(cert.getIssuerDN().getName()).append("\n");
info.append("Valid from: ").append(cert.getNotBefore()).append("\n");
info.append("Valid until: ").append(cert.getNotAfter()).append("\n");
info.append("Serial: ").append(cert.getSerialNumber()).append("\n");
return info.toString();
}
} catch (Exception e) {
Log.e(TAG, "Error getting certificate info", e);
}
return "Certificate info not available";
}
// -----------------------------------------------------------------------
// DER / ASN.1 certificate builder using only java.security APIs
// -----------------------------------------------------------------------
private static byte[] buildSelfSignedCertificateDer(
KeyPair keyPair, PrivateKey privateKey,
BigInteger serial, Date notBefore, Date notAfter,
String distinguishedName) throws Exception {
byte[] tbsCert = buildTbsCertificate(
serial, notBefore, notAfter, distinguishedName,
keyPair.getPublic().getEncoded());
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(tbsCert);
byte[] signature = sig.sign();
byte[] signatureAlgorithm = derSequence(new byte[][]{
derOid(new int[]{1, 2, 840, 113549, 1, 1, 11}),
derNull()
});
byte[] signatureBits = new byte[signature.length + 1];
signatureBits[0] = 0x00;
System.arraycopy(signature, 0, signatureBits, 1, signature.length);
byte[] signatureBitString = derBitString(signatureBits);
return derSequence(new byte[][]{tbsCert, signatureAlgorithm, signatureBitString});
}
private static byte[] buildTbsCertificate(
BigInteger serial, Date notBefore, Date notAfter,
String distinguishedName, byte[] publicKeyEncoded) throws Exception {
byte[] version = derExplicitTag(0, derInteger(BigInteger.valueOf(2)));
byte[] serialNumber = derInteger(serial);
byte[] signatureAlgorithm = derSequence(new byte[][]{
derOid(new int[]{1, 2, 840, 113549, 1, 1, 11}),
derNull()
});
byte[] issuer = encodeDN(distinguishedName);
byte[] subject = issuer;
byte[] validity = derSequence(new byte[][]{
derUtcTime(notBefore),
derUtcTime(notAfter)
});
byte[] subjectPublicKeyInfo = publicKeyEncoded;
return derSequence(new byte[][]{
version, serialNumber, signatureAlgorithm,
issuer, validity, subject, subjectPublicKeyInfo
});
}
private static byte[] encodeDN(String dn) {
String[] parts = dn.split(",");
byte[][] rdnSets = new byte[parts.length][];
for (int i = 0; i < parts.length; i++) {
String part = parts[i].trim();
int eq = part.indexOf('=');
String key = part.substring(0, eq).trim();
String value = part.substring(eq + 1).trim();
rdnSets[i] = derSet(new byte[][]{
derSequence(new byte[][]{
derOidForAttributeType(key),
derUtf8String(value)
})
});
}
return derSequence(rdnSets);
}
private static byte[] derOidForAttributeType(String type) {
switch (type.toUpperCase()) {
case "CN": return derOid(new int[]{2, 5, 4, 3});
case "O": return derOid(new int[]{2, 5, 4, 10});
case "C": return derOid(new int[]{2, 5, 4, 6});
case "OU": return derOid(new int[]{2, 5, 4, 11});
case "ST": return derOid(new int[]{2, 5, 4, 8});
case "L": return derOid(new int[]{2, 5, 4, 7});
default:
throw new IllegalArgumentException("Unsupported attribute type: " + type);
}
}
private static byte[] derSequence(byte[][] items) {
return derConstructed(0x30, items);
}
private static byte[] derSet(byte[][] items) {
return derConstructed(0x31, items);
}
private static byte[] derConstructed(int tag, byte[][] items) {
int totalLen = 0;
for (byte[] item : items) totalLen += item.length;
byte[] content = new byte[totalLen];
int offset = 0;
for (byte[] item : items) {
System.arraycopy(item, 0, content, offset, item.length);
offset += item.length;
}
return derWrap(tag, content);
}
private static byte[] derExplicitTag(int tagNumber, byte[] content) {
return derWrap(0xA0 | tagNumber, content);
}
private static byte[] derInteger(BigInteger value) {
byte[] encoded = value.toByteArray();
return derWrap(0x02, encoded);
}
private static byte[] derBitString(byte[] content) {
return derWrap(0x03, content);
}
private static byte[] derNull() {
return new byte[]{0x05, 0x00};
}
private static byte[] derUtf8String(String s) {
byte[] content = s.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return derWrap(0x0C, content);
}
@SuppressWarnings("deprecation")
private static byte[] derUtcTime(Date date) {
java.util.Calendar cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"));
cal.setTime(date);
String s = String.format(java.util.Locale.US, "%02d%02d%02d%02d%02d%02dZ",
cal.get(java.util.Calendar.YEAR) % 100,
cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH),
cal.get(java.util.Calendar.HOUR_OF_DAY),
cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND));
return derWrap(0x17, s.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
private static byte[] derOid(int[] components) {
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
buf.write(40 * components[0] + components[1]);
for (int i = 2; i < components.length; i++) {
encodeOidComponent(buf, components[i]);
}
return derWrap(0x06, buf.toByteArray());
}
private static void encodeOidComponent(java.io.ByteArrayOutputStream buf, int value) {
if (value < 128) {
buf.write(value);
} else {
byte[] temp = new byte[5];
int pos = temp.length;
temp[--pos] = (byte) (value & 0x7F);
value >>= 7;
while (value > 0) {
temp[--pos] = (byte) (0x80 | (value & 0x7F));
value >>= 7;
}
buf.write(temp, pos, temp.length - pos);
}
}
private static byte[] derWrap(int tag, byte[] content) {
byte[] lenBytes = derLength(content.length);
byte[] result = new byte[1 + lenBytes.length + content.length];
result[0] = (byte) tag;
System.arraycopy(lenBytes, 0, result, 1, lenBytes.length);
System.arraycopy(content, 0, result, 1 + lenBytes.length, content.length);
return result;
}
private static byte[] derLength(int length) {
if (length < 128) {
return new byte[]{(byte) length};
} else if (length < 256) {
return new byte[]{(byte) 0x81, (byte) length};
} else if (length < 65536) {
return new byte[]{(byte) 0x82, (byte) (length >> 8), (byte) length};
} else {
return new byte[]{(byte) 0x83, (byte) (length >> 16), (byte) (length >> 8), (byte) length};
}
}
}

View file

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory android:title="Client Connection">
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="A name for this device"
android:inputType="text"
android:key="mqtt_device_id"
android:summary="%s"
android:title="Device ID" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Your assigned section of the MQTT server"
android:inputType="text"
android:key="mqtt_battlefield"
android:summary="%s"
android:title="KTag Battlefield" />
<SwitchPreferenceCompat
android:key="mqtt_autodiscovery"
android:title="Autodiscovery"
android:summary="Automatically find a broker on the local network"
android:defaultValue="true" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="ssl://mqtt.clubk.club:8883"
android:inputType="textUri"
android:key="mqtt_server_uri"
android:summary="%s"
android:title="KTag Server" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Enter your MQTT username"
android:inputType="text"
android:key="mqtt_username"
android:summary="%s"
android:title="Username" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Enter your MQTT password"
android:inputType="textPassword"
android:key="mqtt_password"
android:summary="shh...it's a secret!"
android:title="Password" />
</PreferenceCategory>
<PreferenceCategory android:title="Broker">
<SwitchPreferenceCompat
android:key="broker_enabled"
android:title="Enable MQTT Broker"
android:summary="Run an embedded MQTT broker on this device"
android:defaultValue="false" />
</PreferenceCategory>
<PreferenceCategory android:title="Network">
<EditTextPreference
android:key="broker_port"
android:title="MQTT Port"
android:summary="Default: 1883"
android:defaultValue="1883"
android:inputType="number" />
<EditTextPreference
android:key="broker_ssl_port"
android:title="SSL Port"
android:summary="Default: 8883"
android:defaultValue="8883"
android:inputType="number"
android:dependency="broker_ssl_enabled" />
</PreferenceCategory>
<PreferenceCategory android:title="Security">
<SwitchPreferenceCompat
android:key="broker_ssl_enabled"
android:title="Enable SSL/TLS"
android:summary="Use a self-signed certificate for encrypted connections"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="broker_auth_enabled"
android:title="Enable Authentication"
android:summary="Require username/password to connect"
android:defaultValue="false" />
<Preference
android:key="broker_manage_users"
android:title="Manage Users"
android:summary="Add or remove user credentials"
android:dependency="broker_auth_enabled" />
</PreferenceCategory>
<PreferenceCategory android:title="Discovery">
<SwitchPreferenceCompat
android:key="broker_nsd_enabled"
android:title="Enable mDNS Discovery"
android:summary="Advertise this broker on the local network"
android:defaultValue="false" />
</PreferenceCategory>
</PreferenceScreen>

34
settings.gradle.kts Normal file
View file

@ -0,0 +1,34 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}
rootProject.name = "KTagApps"
include(":app")
include(":core")
include(":shared-services")
include(":subapp-sample")
include(":subapp-bletool")
include(":subapp-koth")
include(":subapp-medic")
include(":subapp-terminal")
include(":subapp-mine")
include(":subapp-konfigurator")
include(":subapp-sentry")
include(":mqtt-broker")

View file

@ -0,0 +1,53 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.sharedservices"
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("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.preference:preference:1.2.1")
// Compose runtime (required by kotlin.compose plugin)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Coroutines for StateFlow
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// MQTT
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
implementation("com.github.hannesa2:paho.mqtt.android:4.4.2")
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Activity must be declared by consuming subapps with their own parentActivityName -->
</manifest>

View file

@ -0,0 +1,66 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.annotation.XmlRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
/**
* Base activity for displaying preference screens.
* Subapps can use this directly via [createIntent] or subclass for custom behavior.
*/
open class BaseSettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
val preferencesResId = intent.getIntExtra(EXTRA_PREFERENCES_RES_ID, 0)
if (preferencesResId != 0) {
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, BaseSettingsFragment.newInstance(preferencesResId))
.commit()
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
NavUtils.navigateUpFromSameTask(this)
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
internal const val EXTRA_PREFERENCES_RES_ID = "preferences_res_id"
/**
* Create an Intent to launch the settings activity with the given preferences XML.
*/
@JvmStatic
fun createIntent(context: Context, @XmlRes preferencesResId: Int): Intent {
return createIntent(context, preferencesResId, BaseSettingsActivity::class.java)
}
/**
* Create an Intent to launch a settings activity subclass with the given preferences XML.
*/
@JvmStatic
fun <T : BaseSettingsActivity> createIntent(
context: Context,
@XmlRes preferencesResId: Int,
activityClass: Class<T>
): Intent {
return Intent(context, activityClass).apply {
putExtra(EXTRA_PREFERENCES_RES_ID, preferencesResId)
}
}
}
}

View file

@ -0,0 +1,30 @@
package club.clubk.ktag.apps.sharedservices
import android.os.Bundle
import androidx.annotation.XmlRes
import androidx.preference.PreferenceFragmentCompat
/**
* Base fragment for displaying preferences from an XML resource.
*/
open class BaseSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val preferencesResId = arguments?.getInt(ARG_PREFERENCES_RES_ID, 0) ?: 0
if (preferencesResId != 0) {
setPreferencesFromResource(preferencesResId, rootKey)
}
}
companion object {
private const val ARG_PREFERENCES_RES_ID = "preferences_res_id"
fun newInstance(@XmlRes preferencesResId: Int): BaseSettingsFragment {
return BaseSettingsFragment().apply {
arguments = Bundle().apply {
putInt(ARG_PREFERENCES_RES_ID, preferencesResId)
}
}
}
}
}

View file

@ -0,0 +1,196 @@
package club.clubk.ktag.apps.sharedservices
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import club.clubk.ktag.apps.core.DeviceModel
import club.clubk.ktag.apps.core.ble.Packet
import kotlinx.coroutines.launch
/**
* Base ViewModel for subapps that scan for KTag BLE devices.
*
* Provides shared BLE scanning/advertising infrastructure and DeviceInfo integration.
* Subclasses must implement [onStatusPacket] to handle Status packet content and
* [updateDeviceInfoFromRepository] to apply name/version updates to their device list.
*/
abstract class BleViewModel : ViewModel() {
protected abstract val logTag: String
// BLE
protected var btScanner: BluetoothLeScanner? = null
protected var btAdvertiser: BluetoothLeAdvertiser? = null
protected var isAdvertising = false
protected var advertisingJob: kotlinx.coroutines.Job? = null
// Device Info
protected var deviceInfoRepository: DeviceInfoRepository? = null
protected var deviceInfoMqttSync: DeviceInfoMqttSync? = null
// Context for UI interactions (e.g. Toast)
protected var appContext: Context? = null
/** Listener that shows incoming MQTT Listen messages as a Toast. */
val listenListener = object : MqttMessageListener {
override fun onMessageReceived(topic: String, payload: ByteArray) {
val text = String(payload)
Handler(Looper.getMainLooper()).post {
appContext?.let { ctx ->
Toast.makeText(ctx, "MQTT: $text", Toast.LENGTH_LONG).show()
}
}
}
}
@SuppressLint("MissingPermission")
protected val advertisingCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d(logTag, "Advertising onStartSuccess")
isAdvertising = true
}
override fun onStartFailure(errorCode: Int) {
Log.e(logTag, "Advertising onStartFailure: $errorCode")
isAdvertising = false
}
}
@SuppressLint("MissingPermission")
protected val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (Packet.IsKTagStatusPacket(result)) {
onStatusPacket(result)
} else if (Packet.IsKTagHelloPacket(result)) {
val hello = Packet.Hello(result)
val changed = deviceInfoRepository?.setInfo(
result.device.address,
DeviceInfo(
name = hello.device_name,
deviceType = hello.device_type,
deviceTypeName = Packet.getDeviceTypeName(hello.device_type),
majorVersion = hello.systemK_major_version,
minorVersion = hello.systemK_minor_version
)
) ?: false
if (changed) {
deviceInfoMqttSync?.publishCurrentInfo()
}
}
}
override fun onBatchScanResults(results: List<ScanResult>) {
results.forEach { onScanResult(0, it) }
}
override fun onScanFailed(errorCode: Int) {
Log.e(logTag, "BLE Scan failed with error code: $errorCode")
}
}
/** Called when a Status packet is received; subclass handles the payload. */
protected abstract fun onStatusPacket(result: ScanResult)
@SuppressLint("MissingPermission")
protected fun initBluetooth(context: Context) {
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
btScanner = btAdapter?.bluetoothLeScanner
btAdvertiser = btAdapter?.bluetoothLeAdvertiser
}
protected fun initDeviceInfo(context: Context) {
try {
deviceInfoRepository = DeviceInfoRepository.getInstance(context)
deviceInfoMqttSync = DeviceInfoMqttSync(deviceInfoRepository!!)
deviceInfoMqttSync?.connect()
viewModelScope.launch {
deviceInfoRepository?.deviceInfo?.collect { infoMap ->
updateDeviceInfoFromRepository(infoMap)
}
}
} catch (e: Exception) {
Log.e(logTag, "Failed to initialize device info: ${e.message}")
}
}
/**
* Called when the DeviceInfo repository updates. Subclass should call
* [applyDeviceInfoUpdate] with its own device list.
*/
protected abstract fun updateDeviceInfoFromRepository(infoMap: Map<String, DeviceInfo>)
/**
* Applies name/type/version updates from [infoMap] to [devices] in place.
*/
protected fun applyDeviceInfoUpdate(
devices: MutableList<DeviceModel>,
infoMap: Map<String, DeviceInfo>
) {
val iterator = devices.listIterator()
while (iterator.hasNext()) {
val device = iterator.next()
val info = infoMap[device.bleAddress] ?: continue
val newName = info.displayName.ifEmpty { null } ?: device.name
val newDeviceType = info.deviceTypeName
val newVersion = String.format("SystemK v%d.%02d", info.majorVersion, info.minorVersion)
if (device.name != newName || device.deviceType != newDeviceType || device.version != newVersion) {
iterator.set(device.copy(name = newName, deviceType = newDeviceType, version = newVersion))
}
}
}
fun renameDevice(macAddress: String, newName: String) {
deviceInfoRepository?.setName(macAddress, newName)
deviceInfoMqttSync?.publishCurrentInfo()
}
@SuppressLint("MissingPermission")
protected fun startBleScanning() {
try {
btScanner?.startScan(scanCallback)
Log.i(logTag, "Started scanning for KTag devices.")
} catch (e: Exception) {
Log.e(logTag, "Error starting scan: ", e)
}
}
@SuppressLint("MissingPermission")
protected fun stopBleScanning() {
try {
btScanner?.stopScan(scanCallback)
Log.i(logTag, "Stopped scanning for KTag devices.")
} catch (e: Exception) {
Log.e(logTag, "Error stopping scan: ", e)
}
}
@SuppressLint("MissingPermission")
protected fun stopAdvertising() {
try {
btAdvertiser?.stopAdvertising(advertisingCallback)
} catch (e: Exception) {
Log.e(logTag, "Error stopping advertising: ", e)
}
isAdvertising = false
advertisingJob?.cancel()
advertisingJob = null
}
protected fun cleanupDeviceInfo() {
deviceInfoMqttSync?.cleanup()
}
}

View file

@ -0,0 +1,75 @@
package club.clubk.ktag.apps.sharedservices
import android.util.Log
import org.json.JSONObject
/**
* Handles MQTT synchronization of device info across devices.
* Publishes info changes as retained messages and subscribes to receive updates.
*/
class DeviceInfoMqttSync(
private val repository: DeviceInfoRepository
) : MqttMessageListener {
companion object {
private const val TAG = "DeviceInfoMqttSync"
private const val QOS = 1
}
private val topic: String
get() = "KTag/${SharedMqttClient.battlefield}/DeviceInfo"
/**
* Subscribe to device info topic via the shared MQTT client.
*/
fun connect() {
SharedMqttClient.subscribe(topic, this)
}
override fun onMessageReceived(topic: String, payload: ByteArray) {
handleIncomingMessage(payload)
}
private fun handleIncomingMessage(payload: ByteArray) {
try {
val json = String(payload)
val jsonObject = JSONObject(json)
val incoming = mutableMapOf<String, DeviceInfo>()
jsonObject.keys().forEach { key ->
val infoObj = jsonObject.getJSONObject(key)
incoming[key] = DeviceInfo(
name = infoObj.optString("name", ""),
userDefinedName = infoObj.optString("userDefinedName", ""),
deviceType = infoObj.optInt("deviceType", 0),
deviceTypeName = infoObj.optString("deviceTypeName", ""),
majorVersion = infoObj.optInt("majorVersion", 0),
minorVersion = infoObj.optInt("minorVersion", 0)
)
}
if (incoming.isNotEmpty()) {
Log.i(TAG, "Received ${incoming.size} device info entries from MQTT")
repository.mergeInfo(incoming)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse incoming device info: ${e.message}")
}
}
/**
* Publish current device info to MQTT as a retained message.
*/
fun publishCurrentInfo() {
val json = repository.getAllInfoAsJson()
SharedMqttClient.publish(topic, json.toByteArray(), QOS, retained = true)
Log.i(TAG, "Published device info to $topic")
}
/**
* Cleanup MQTT resources.
*/
fun cleanup() {
SharedMqttClient.unsubscribe(topic, this)
}
}

View file

@ -0,0 +1,9 @@
package club.clubk.ktag.apps.sharedservices
/**
* Preference keys for device info storage.
*/
object DeviceInfoPreferenceKeys {
/** Key for JSON blob containing MAC address to DeviceInfo mappings */
const val DEVICE_INFO_JSON = "device_info_json"
}

View file

@ -0,0 +1,181 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
/**
* Device info extracted from Hello packets.
*/
data class DeviceInfo(
val name: String = "",
val userDefinedName: String = "",
val deviceType: Int = 0,
val deviceTypeName: String = "",
val majorVersion: Int = 0,
val minorVersion: Int = 0
) {
/** Returns userDefinedName if set, otherwise name. */
val displayName: String
get() = userDefinedName.ifEmpty { name }
}
/**
* Singleton repository for managing device info stored in SharedPreferences.
* Provides reactive updates via StateFlow for Compose UI integration.
*/
class DeviceInfoRepository private constructor(context: Context) {
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val _deviceInfo = MutableStateFlow<Map<String, DeviceInfo>>(emptyMap())
/** Reactive stream of device info mappings (MAC address -> DeviceInfo) */
val deviceInfo: StateFlow<Map<String, DeviceInfo>> = _deviceInfo.asStateFlow()
private val prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == DeviceInfoPreferenceKeys.DEVICE_INFO_JSON) {
_deviceInfo.value = loadInfoFromPrefs()
}
}
init {
_deviceInfo.value = loadInfoFromPrefs()
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
}
private fun loadInfoFromPrefs(): Map<String, DeviceInfo> {
val json = prefs.getString(DeviceInfoPreferenceKeys.DEVICE_INFO_JSON, null)
?: return emptyMap()
return try {
val jsonObject = JSONObject(json)
val map = mutableMapOf<String, DeviceInfo>()
jsonObject.keys().forEach { key ->
val infoObj = jsonObject.getJSONObject(key)
map[key] = DeviceInfo(
name = infoObj.optString("name", ""),
userDefinedName = infoObj.optString("userDefinedName", ""),
deviceType = infoObj.optInt("deviceType", 0),
deviceTypeName = infoObj.optString("deviceTypeName", ""),
majorVersion = infoObj.optInt("majorVersion", 0),
minorVersion = infoObj.optInt("minorVersion", 0)
)
}
map
} catch (e: Exception) {
emptyMap()
}
}
private fun saveInfoToPrefs(info: Map<String, DeviceInfo>) {
val jsonObject = JSONObject()
info.forEach { (mac, deviceInfo) ->
val infoObj = JSONObject().apply {
put("name", deviceInfo.name)
put("userDefinedName", deviceInfo.userDefinedName)
put("deviceType", deviceInfo.deviceType)
put("deviceTypeName", deviceInfo.deviceTypeName)
put("majorVersion", deviceInfo.majorVersion)
put("minorVersion", deviceInfo.minorVersion)
}
jsonObject.put(mac, infoObj)
}
prefs.edit()
.putString(DeviceInfoPreferenceKeys.DEVICE_INFO_JSON, jsonObject.toString())
.apply()
}
/**
* Get the info for a device by MAC address.
*/
fun getInfo(macAddress: String): DeviceInfo? {
return _deviceInfo.value[macAddress]
}
/**
* Get the name for a device by MAC address.
*/
fun getName(macAddress: String): String {
val info = _deviceInfo.value[macAddress] ?: return "KTag Device $macAddress"
val display = info.displayName
return display.ifEmpty { "KTag Device $macAddress" }
}
/**
* Set or update the user-defined name for a device.
*/
fun setName(macAddress: String, name: String) {
val existing = _deviceInfo.value[macAddress] ?: DeviceInfo()
if (existing.userDefinedName == name) return
val updated = _deviceInfo.value.toMutableMap()
updated[macAddress] = existing.copy(userDefinedName = name)
saveInfoToPrefs(updated)
_deviceInfo.value = updated
}
/**
* Set or update the info for a device.
* Returns true if the info actually changed.
*/
fun setInfo(macAddress: String, info: DeviceInfo): Boolean {
val existing = _deviceInfo.value[macAddress]
val merged = if (existing != null) {
info.copy(userDefinedName = existing.userDefinedName)
} else {
info
}
if (existing == merged) return false
val updated = _deviceInfo.value.toMutableMap()
updated[macAddress] = merged
saveInfoToPrefs(updated)
_deviceInfo.value = updated
return true
}
/**
* Merge incoming info with existing info.
* Incoming info takes precedence.
*/
fun mergeInfo(incoming: Map<String, DeviceInfo>) {
val merged = _deviceInfo.value.toMutableMap()
merged.putAll(incoming)
saveInfoToPrefs(merged)
_deviceInfo.value = merged
}
/**
* Get all device info as a JSON string for MQTT publishing.
*/
fun getAllInfoAsJson(): String {
val jsonObject = JSONObject()
_deviceInfo.value.forEach { (mac, info) ->
val infoObj = JSONObject().apply {
put("name", info.name)
put("userDefinedName", info.userDefinedName)
put("deviceType", info.deviceType)
put("deviceTypeName", info.deviceTypeName)
put("majorVersion", info.majorVersion)
put("minorVersion", info.minorVersion)
}
jsonObject.put(mac, infoObj)
}
return jsonObject.toString()
}
companion object {
@Volatile
private var instance: DeviceInfoRepository? = null
fun getInstance(context: Context): DeviceInfoRepository {
return instance ?: synchronized(this) {
instance ?: DeviceInfoRepository(context.applicationContext).also {
instance = it
}
}
}
}
}

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for device detection settings.
* These keys must match the android:key attributes in broker settings XML.
*/
object DevicePreferenceKeys {
const val DEVICE_TTL = "device_ttl_s"
const val DEFAULT_DEVICE_TTL = "5" // seconds
}

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for game timing settings shared across subapps.
* These keys must match the android:key attributes in settings XML files.
*/
object GamePreferenceKeys {
const val GAME_DURATION = "game_duration_min" // stored as minutes
const val DEFAULT_GAME_DURATION = "10"
const val TIME_UNTIL_COUNTDOWN = "countdown_delay_s" // stored as seconds
const val DEFAULT_TIME_UNTIL_COUNTDOWN = "30"
}

View file

@ -0,0 +1,129 @@
package club.clubk.ktag.apps.sharedservices
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
object LocationPublisher {
private const val TAG = "LocationPublisher"
private const val UPDATE_INTERVAL_MS = 30_000L
private const val MIN_DISTANCE_M = 0f
private var locationManager: LocationManager? = null
private var locationListener: LocationListener? = null
private var appContext: Context? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var statusJob: Job? = null
fun start(context: Context) {
appContext = context.applicationContext
locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
statusJob?.cancel()
statusJob = scope.launch {
SharedMqttClient.status.collect { status ->
if (status is MqttClientStatus.Connected) {
requestLocationUpdates()
} else {
stopLocationUpdates()
}
}
}
}
fun stop() {
statusJob?.cancel()
statusJob = null
stopLocationUpdates()
appContext = null
}
private fun requestLocationUpdates() {
val ctx = appContext ?: return
val lm = locationManager ?: return
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
) {
Log.w(TAG, "Location permission not granted, skipping GPS publishing")
return
}
if (locationListener != null) return // already registered
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
publishLocation(location)
}
@Deprecated("Deprecated in API 29")
override fun onStatusChanged(provider: String, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
locationListener = listener
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.filter { lm.isProviderEnabled(it) }
if (providers.isEmpty()) {
Log.w(TAG, "No location providers enabled")
locationListener = null
return
}
for (provider in providers) {
try {
lm.requestLocationUpdates(provider, UPDATE_INTERVAL_MS, MIN_DISTANCE_M, listener, Looper.getMainLooper())
Log.i(TAG, "Registered location updates from $provider")
} catch (e: Exception) {
Log.e(TAG, "Failed to register $provider: ${e.message}")
}
}
// Publish last known location immediately
providers.firstNotNullOfOrNull { lm.getLastKnownLocation(it) }?.let {
publishLocation(it)
}
}
private fun stopLocationUpdates() {
val listener = locationListener ?: return
locationManager?.removeUpdates(listener)
locationListener = null
Log.d(TAG, "Stopped location updates")
}
private fun publishLocation(location: Location) {
val battlefield = SharedMqttClient.battlefield
val deviceId = SharedMqttClient.currentDeviceId
val topic = "KTag/$battlefield/Devices/$deviceId/Location"
val payload = buildString {
append("{")
append("\"lat\":").append(location.latitude)
append(",\"lon\":").append(location.longitude)
if (location.hasAltitude()) append(",\"alt\":").append(location.altitude)
if (location.hasAccuracy()) append(",\"accuracy\":").append(location.accuracy)
append("}")
}
SharedMqttClient.publish(topic, payload.toByteArray(), qos = 1, retained = true)
Log.d(TAG, "Published location to $topic: $payload")
}
}

View file

@ -0,0 +1,48 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import androidx.preference.PreferenceManager
/**
* Enum representing the auto-heal setting options.
*/
enum class AutoHealMode(val value: Int) {
NONE(0),
RED(1),
BLUE(2),
ALL(3);
companion object {
fun fromInt(value: Int): AutoHealMode = entries.find { it.value == value } ?: NONE
}
}
/**
* Data class holding Medic configuration loaded from SharedPreferences.
*/
data class MedicConfig(
val minRssi: Int,
val autoHeal: AutoHealMode
) {
companion object {
/**
* Load Medic configuration from the app's default SharedPreferences.
*/
@JvmStatic
fun fromPreferences(context: Context): MedicConfig {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return MedicConfig(
minRssi = prefs.getString(
MedicPreferenceKeys.MIN_RSSI,
MedicPreferenceKeys.DEFAULT_MIN_RSSI
)?.toIntOrNull() ?: MedicPreferenceKeys.DEFAULT_MIN_RSSI.toInt(),
autoHeal = AutoHealMode.fromInt(
prefs.getString(
MedicPreferenceKeys.AUTO_HEAL,
MedicPreferenceKeys.DEFAULT_AUTO_HEAL
)?.toIntOrNull() ?: MedicPreferenceKeys.DEFAULT_AUTO_HEAL.toInt()
)
)
}
}
}

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for Medic settings.
* These keys must match the android:key attributes in medic preference XML files.
*/
object MedicPreferenceKeys {
const val MIN_RSSI = "min_rssi"
const val AUTO_HEAL = "auto_heal"
const val DEFAULT_MIN_RSSI = "-60"
const val DEFAULT_AUTO_HEAL = "0" // 0=None, 1=Red, 2=Blue, 3=All
}

View file

@ -0,0 +1,52 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import androidx.preference.PreferenceManager
/**
* Enum representing the target team setting options.
*/
enum class TargetTeamMode(val value: Int) {
RED(1),
BLUE(2),
ALL(3);
companion object {
fun fromInt(value: Int): TargetTeamMode = entries.find { it.value == value } ?: ALL
}
}
/**
* Data class holding Mine configuration loaded from SharedPreferences.
*/
data class MineConfig(
val minRssi: Int,
val damage: Int,
val targetTeam: TargetTeamMode
) {
companion object {
/**
* Load Mine configuration from the app's default SharedPreferences.
*/
@JvmStatic
fun fromPreferences(context: Context): MineConfig {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return MineConfig(
minRssi = prefs.getString(
MinePreferenceKeys.MIN_RSSI,
MinePreferenceKeys.DEFAULT_MIN_RSSI
)?.toIntOrNull() ?: MinePreferenceKeys.DEFAULT_MIN_RSSI.toInt(),
damage = prefs.getString(
MinePreferenceKeys.DAMAGE,
MinePreferenceKeys.DEFAULT_DAMAGE
)?.toIntOrNull() ?: MinePreferenceKeys.DEFAULT_DAMAGE.toInt(),
targetTeam = TargetTeamMode.fromInt(
prefs.getString(
MinePreferenceKeys.TARGET_TEAM,
MinePreferenceKeys.DEFAULT_TARGET_TEAM
)?.toIntOrNull() ?: MinePreferenceKeys.DEFAULT_TARGET_TEAM.toInt()
)
)
}
}
}

View file

@ -0,0 +1,17 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for Mine settings.
* These keys must match the android:key attributes in mine preference XML files.
*/
object MinePreferenceKeys {
const val MIN_RSSI = "mine_min_rssi"
const val DAMAGE = "mine_damage"
const val TARGET_TEAM = "mine_target_team"
const val REARM_TIME = "mine_rearm_time"
const val DEFAULT_MIN_RSSI = "-60"
const val DEFAULT_DAMAGE = "100"
const val DEFAULT_TARGET_TEAM = "3" // 1=Red, 2=Blue, 3=All
const val DEFAULT_REARM_TIME = "5" // seconds
}

View file

@ -0,0 +1,45 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import androidx.preference.PreferenceManager
import java.util.UUID
/**
* Data class holding MQTT connection configuration loaded from SharedPreferences.
*/
data class MqttConnectionConfig(
val deviceId: String,
val serverUri: String,
val username: String,
val password: String,
val battlefield: String,
val autodiscovery: Boolean
) {
companion object {
/**
* Load MQTT configuration from the app's default SharedPreferences.
*/
@JvmStatic
fun fromPreferences(context: Context): MqttConnectionConfig {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return MqttConnectionConfig(
deviceId = prefs.getString(MqttPreferenceKeys.DEVICE_ID, null)
?.takeIf { it.isNotBlank() }
?: UUID.randomUUID().toString().also {
prefs.edit().putString(MqttPreferenceKeys.DEVICE_ID, it).apply()
},
serverUri = prefs.getString(
MqttPreferenceKeys.SERVER_URI,
MqttPreferenceKeys.DEFAULT_SERVER_URI
) ?: MqttPreferenceKeys.DEFAULT_SERVER_URI,
username = prefs.getString(MqttPreferenceKeys.USERNAME, "") ?: "",
password = prefs.getString(MqttPreferenceKeys.PASSWORD, "") ?: "",
battlefield = prefs.getString(
MqttPreferenceKeys.BATTLEFIELD,
MqttPreferenceKeys.DEFAULT_BATTLEFIELD
) ?: MqttPreferenceKeys.DEFAULT_BATTLEFIELD,
autodiscovery = prefs.getBoolean(MqttPreferenceKeys.AUTODISCOVERY, true)
)
}
}
}

View file

@ -0,0 +1,17 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for MQTT settings.
* These keys must match the android:key attributes in preference XML files.
*/
object MqttPreferenceKeys {
const val DEVICE_ID = "mqtt_device_id"
const val SERVER_URI = "mqtt_server_uri"
const val USERNAME = "mqtt_username"
const val PASSWORD = "mqtt_password"
const val BATTLEFIELD = "mqtt_battlefield"
const val AUTODISCOVERY = "mqtt_autodiscovery"
const val DEFAULT_SERVER_URI = "ssl://mqtt.clubk.club:8883"
const val DEFAULT_BATTLEFIELD = "My Yard"
}

View file

@ -0,0 +1,11 @@
package club.clubk.ktag.apps.sharedservices
import android.content.SharedPreferences
/** Reads a String preference and converts it to Int, falling back to [default]. */
fun SharedPreferences.getIntPref(key: String, default: String): Int =
getString(key, default)?.toIntOrNull() ?: default.toInt()
/** Reads a String preference and converts it to Long, falling back to [default]. */
fun SharedPreferences.getLongPref(key: String, default: String): Long =
getString(key, default)?.toLongOrNull() ?: default.toLong()

View file

@ -0,0 +1,33 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.content.Intent
import androidx.annotation.XmlRes
import club.clubk.ktag.apps.core.SubApp
/**
* Extended SubApp interface for subapps that provide user-configurable settings.
*
* Subapps implementing this interface gain access to common settings infrastructure
* including BaseSettingsActivity and shared preference utilities.
*/
interface SettingsSubApp : SubApp {
/**
* The XML resource ID for this subapp's preferences.
*/
@get:XmlRes
val settingsPreferencesResId: Int
/**
* Creates an Intent to launch the settings activity for this subapp.
*/
fun createSettingsIntent(context: Context): Intent {
return BaseSettingsActivity.createIntent(context, settingsPreferencesResId)
}
/**
* Whether this subapp uses MQTT functionality.
*/
val usesMqtt: Boolean
get() = false
}

View file

@ -0,0 +1,411 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.net.nsd.NsdManager
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.eclipse.paho.client.mqttv3.IMqttActionListener
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.IMqttToken
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttClient
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import info.mqtt.android.service.Ack
import info.mqtt.android.service.MqttAndroidClient
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
sealed class MqttClientStatus {
data object Disconnected : MqttClientStatus()
data object Connecting : MqttClientStatus()
data object Connected : MqttClientStatus()
data class Error(val message: String) : MqttClientStatus()
}
interface MqttMessageListener {
fun onMessageReceived(topic: String, payload: ByteArray)
}
object SharedMqttClient {
private const val TAG = "SharedMqttClient"
private const val DISCOVERY_TIMEOUT_MS = 5000L
private const val DISCOVERY_RETRY_DELAY_MS = 5000L
@Volatile
private var autodiscoveryActive = false
private val _status = MutableStateFlow<MqttClientStatus>(MqttClientStatus.Disconnected)
val status: StateFlow<MqttClientStatus> = _status.asStateFlow()
private var client: MqttAndroidClient? = null
private var currentConfig: MqttConnectionConfig? = null
// topic -> set of listeners
private val subscriptions = ConcurrentHashMap<String, MutableSet<MqttMessageListener>>()
val battlefield: String
get() = currentConfig?.battlefield ?: MqttPreferenceKeys.DEFAULT_BATTLEFIELD
val currentDeviceId: String
get() = currentConfig?.deviceId ?: android.os.Build.MODEL
fun connect(context: Context) {
val config = MqttConnectionConfig.fromPreferences(context)
if (config.autodiscovery) {
connectWithAutodiscovery(context, config)
} else {
connectWithConfig(context, config)
}
}
private fun connectWithConfig(context: Context, config: MqttConnectionConfig) {
// If already connected with same config, no-op
if (client?.isConnected == true && config == currentConfig) {
Log.d(TAG, "Already connected with same config, skipping")
return
}
// Tear down existing connection
disconnectInternal()
currentConfig = config
_status.value = MqttClientStatus.Connecting
connectToServer(context, config.serverUri, config.username, config.password)
}
private fun connectWithAutodiscovery(context: Context, config: MqttConnectionConfig) {
// For autodiscovery, the effective credentials are KTag / battlefield
val effectiveUsername = "KTag"
val effectivePassword = config.battlefield
// If already connected via autodiscovery with same battlefield, no-op
if (client?.isConnected == true && currentConfig?.autodiscovery == true
&& currentConfig?.battlefield == config.battlefield
&& currentConfig?.deviceId == config.deviceId) {
Log.d(TAG, "Already connected via autodiscovery with same battlefield, skipping")
return
}
disconnectInternal()
currentConfig = config
_status.value = MqttClientStatus.Connecting
autodiscoveryActive = true
// Run mDNS discovery on a background thread, retrying until found
Thread {
while (autodiscoveryActive) {
val discoveredUri = discoverBroker(context)
if (discoveredUri != null) {
Log.i(TAG, "Autodiscovery found broker at $discoveredUri")
if (autodiscoveryActive) {
connectToServer(context, discoveredUri, effectiveUsername, effectivePassword)
}
return@Thread
}
Log.w(TAG, "Autodiscovery found no brokers on LAN, retrying in ${DISCOVERY_RETRY_DELAY_MS}ms")
_status.value = MqttClientStatus.Error("No broker found on LAN, retrying\u2026")
try {
Thread.sleep(DISCOVERY_RETRY_DELAY_MS)
} catch (_: InterruptedException) {
return@Thread
}
}
}.start()
}
private fun discoverBroker(context: Context): String? {
val nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager
if (nsdManager == null) {
Log.e(TAG, "NSD service not available")
return null
}
val latch = CountDownLatch(1)
var resultUri: String? = null
val discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "mDNS discovery started")
}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val callback = object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.w(TAG, "ServiceInfoCallback registration failed: $errorCode")
}
override fun onServiceUpdated(si: NsdServiceInfo) {
nsdManager.unregisterServiceInfoCallback(this)
handleResolvedService(si, resultUri) { uri ->
resultUri = uri
latch.countDown()
}
}
override fun onServiceLost() {
nsdManager.unregisterServiceInfoCallback(this)
}
override fun onServiceInfoCallbackUnregistered() {}
}
nsdManager.registerServiceInfoCallback(serviceInfo, { it.run() }, callback)
} else {
@Suppress("DEPRECATION")
nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(si: NsdServiceInfo, errorCode: Int) {
Log.w(TAG, "Failed to resolve service: $errorCode")
}
override fun onServiceResolved(si: NsdServiceInfo) {
handleResolvedService(si, resultUri) { uri ->
resultUri = uri
latch.countDown()
}
}
})
}
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "mDNS discovery start failed: $errorCode")
latch.countDown()
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
}
nsdManager.discoverServices("_mqtt._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
// Wait for first result or timeout
latch.await(DISCOVERY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
try {
nsdManager.stopServiceDiscovery(discoveryListener)
} catch (_: Exception) {}
return resultUri
}
private fun handleResolvedService(
si: NsdServiceInfo,
currentResult: String?,
onFound: (String) -> Unit
) {
val purpose = si.attributes["purpose"]?.let { String(it) }
if (purpose != "KTag MQTT Broker") {
Log.d(TAG, "Skipping non-KTag broker: ${si.serviceName} (purpose=$purpose)")
return
}
val host = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
si.hostAddresses.firstOrNull()?.hostAddress
} else {
@Suppress("DEPRECATION")
si.host?.hostAddress
} ?: return
val port = si.port
Log.i(TAG, "Resolved KTag MQTT broker: $host:$port")
if (currentResult == null) {
onFound("tcp://$host:$port")
}
}
private fun connectToServer(context: Context, serverUri: String, username: String, password: String) {
try {
val clientId = currentConfig?.deviceId ?: android.os.Build.MODEL
val newClient = MqttAndroidClient(
context.applicationContext,
serverUri,
clientId,
Ack.AUTO_ACK
)
newClient.setCallback(object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String) {
Log.i(TAG, "Connected to $serverURI (reconnect=$reconnect)")
_status.value = MqttClientStatus.Connected
resubscribeAll()
}
override fun connectionLost(cause: Throwable?) {
Log.w(TAG, "Connection lost: ${cause?.message}")
_status.value = MqttClientStatus.Disconnected
}
override fun messageArrived(topic: String, message: MqttMessage) {
val listeners = subscriptions[topic]
if (listeners != null) {
synchronized(listeners) {
for (listener in listeners) {
try {
listener.onMessageReceived(topic, message.payload)
} catch (e: Exception) {
Log.e(TAG, "Error in message listener: ${e.message}")
}
}
}
}
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {}
})
val options = MqttConnectOptions().apply {
isCleanSession = true
isAutomaticReconnect = true
keepAliveInterval = 15
userName = username
this.password = password.toCharArray()
}
newClient.connect(options)?.setActionCallback(object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken?) {
Log.i(TAG, "MQTT connection successful")
}
override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) {
Log.e(TAG, "MQTT connection failed: ${exception?.message}")
_status.value = MqttClientStatus.Error(
exception?.message ?: "Connection failed"
)
}
})
client = newClient
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize MQTT: ${e.message}")
_status.value = MqttClientStatus.Error(e.message ?: "Initialization failed")
}
}
fun disconnect() {
disconnectInternal()
_status.value = MqttClientStatus.Disconnected
}
private fun disconnectInternal() {
autodiscoveryActive = false
try {
client?.unregisterResources()
client?.disconnect()
} catch (e: Exception) {
Log.e(TAG, "Error during disconnect: ${e.message}")
}
client = null
}
fun subscribe(topic: String, listener: MqttMessageListener) {
val listeners = subscriptions.getOrPut(topic) { mutableSetOf() }
val needsBrokerSubscribe: Boolean
synchronized(listeners) {
needsBrokerSubscribe = listeners.isEmpty()
listeners.add(listener)
}
if (needsBrokerSubscribe) {
subscribeToBroker(topic)
}
}
fun unsubscribe(topic: String, listener: MqttMessageListener) {
val listeners = subscriptions[topic] ?: return
val needsBrokerUnsubscribe: Boolean
synchronized(listeners) {
listeners.remove(listener)
needsBrokerUnsubscribe = listeners.isEmpty()
}
if (needsBrokerUnsubscribe) {
subscriptions.remove(topic)
unsubscribeFromBroker(topic)
}
}
/**
* Publishes a timestamped hello message to "KTag/<battlefield>/<topicPath>/Hello".
* [appName] is used as the subject of the message, e.g. "KTag Medic App".
*/
fun publishHello(appName: String, topicPath: String) {
try {
val bf = battlefield
val sdf = SimpleDateFormat("EEE, MMM d, yyyy HH:mm", Locale.getDefault())
val helloString = "$appName started on ${sdf.format(Date())}."
publish("KTag/$bf/$topicPath/Hello", helloString.toByteArray(), qos = 2, retained = false)
} catch (e: Exception) {
Log.e(TAG, "Failed to publish hello: ${e.message}")
}
}
fun publish(topic: String, payload: ByteArray, qos: Int = 1, retained: Boolean = false) {
val c = client
if (c == null || !c.isConnected) {
Log.w(TAG, "Not connected, skipping publish to $topic")
return
}
try {
val message = MqttMessage(payload).apply {
this.qos = qos
isRetained = retained
}
c.publish(topic, message)
Log.d(TAG, "Published to $topic")
} catch (e: Exception) {
Log.e(TAG, "Failed to publish to $topic: ${e.message}")
}
}
private fun subscribeToBroker(topic: String) {
val c = client
if (c == null || !c.isConnected) {
Log.d(TAG, "Not connected, will subscribe to $topic on reconnect")
return
}
try {
c.subscribe(topic, 1)?.setActionCallback(object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken?) {
Log.i(TAG, "Subscribed to $topic")
}
override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) {
Log.e(TAG, "Failed to subscribe to $topic: ${exception?.message}")
}
})
} catch (e: Exception) {
Log.e(TAG, "Error subscribing to $topic: ${e.message}")
}
}
private fun unsubscribeFromBroker(topic: String) {
val c = client
if (c == null || !c.isConnected) return
try {
c.unsubscribe(topic)
} catch (e: Exception) {
Log.e(TAG, "Error unsubscribing from $topic: ${e.message}")
}
}
private fun resubscribeAll() {
for (topic in subscriptions.keys) {
subscribeToBroker(topic)
}
}
}

View file

@ -0,0 +1,47 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.util.AttributeSet
import androidx.preference.EditTextPreference
/**
* An EditTextPreference that displays its current value as the summary,
* with optional custom hint text when value is empty.
*/
class SummarizedEditTextPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.editTextPreferenceStyle,
defStyleRes: Int = 0
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
private val customHint: CharSequence?
init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.SummarizedEditTextPreference,
defStyleAttr,
defStyleRes
)
try {
customHint = typedArray.getString(R.styleable.SummarizedEditTextPreference_customHint)
} finally {
typedArray.recycle()
}
}
override fun getSummary(): CharSequence? {
val text = text
return if (text.isNullOrEmpty()) {
customHint ?: "Not set"
} else {
val summaryPattern = super.getSummary()
if (summaryPattern != null) {
String.format(summaryPattern.toString(), text)
} else {
text
}
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/settings_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SummarizedEditTextPreference">
<attr name="customHint" format="string" />
</declare-styleable>
</resources>

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,506 @@
package club.clubk.ktag.apps.bletool
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Intent
import android.location.LocationManager
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import club.clubk.ktag.apps.core.ble.Packet
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import club.clubk.ktag.apps.bletool.ui.theme.KTagBLEToolTheme
private const val TAG = "BLE Scanner"
class ScannerActivity : ComponentActivity() {
private var isScanning by mutableStateOf(false)
private var showLocationDialog by mutableStateOf(false)
private val scannedDevices = mutableStateMapOf<String, KTagPacket>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
KTagBLEToolTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ScannerScreen(
isScanning = isScanning,
devices = scannedDevices,
onStartScan = { startScanning() },
onStopScan = { stopScanning() }
)
if (showLocationDialog) {
AlertDialog(
onDismissRequest = { showLocationDialog = false },
title = { Text("Location Services Required") },
text = { Text("BLE scanning requires Location Services to be enabled. Please enable Location in your device settings.") },
confirmButton = {
TextButton(onClick = {
showLocationDialog = false
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}) {
Text("Open Settings")
}
},
dismissButton = {
TextButton(onClick = { showLocationDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
}
}
private fun isLocationEnabled(): Boolean {
val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
return locationManager.isLocationEnabled
}
@SuppressLint("MissingPermission")
private fun startScanning() {
if (!isLocationEnabled()) {
Log.w(TAG, "Location services are disabled, cannot scan")
showLocationDialog = true
return
}
scannedDevices.clear()
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
val scanner = bluetoothManager.adapter.bluetoothLeScanner ?: run {
Log.e(TAG, "BLE scanning not supported")
return
}
val filter = ScanFilter.Builder()
.setManufacturerData(
0xFFFF,
byteArrayOf(0x4B, 0x54, 0x61, 0x67) // "KTag"
)
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
scanner.startScan(listOf(filter), settings, scanCallback)
isScanning = true
}
private fun stopScanning() {
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
val scanner = bluetoothManager.adapter.bluetoothLeScanner
scanner?.stopScan(scanCallback)
isScanning = false
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
if (data.size >= 4 &&
data[0] == 0x4B.toByte() && // K
data[1] == 0x54.toByte() && // T
data[2] == 0x61.toByte() && // a
data[3] == 0x67.toByte() // g
) {
val packet = parseKTagPacket(data)
scannedDevices[result.device.address] = packet
}
}
}
override fun onScanFailed(errorCode: Int) {
val errorMessage = when (errorCode) {
SCAN_FAILED_ALREADY_STARTED -> "Already started"
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
else -> "Unknown error $errorCode"
}
Log.e(TAG, "BLE Scan failed: $errorMessage")
isScanning = false
}
}
}
@Composable
fun ScannerScreen(
isScanning: Boolean,
devices: Map<String, KTagPacket>,
onStartScan: () -> Unit,
onStopScan: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
TitleBox()
Button(
onClick = if (isScanning) onStopScan else onStartScan,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Text(if (isScanning) "Stop Scanning" else "Start Scanning")
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(devices.entries.toList()) { (address, packet) ->
PacketCard(address = address, packet = packet)
}
}
}
}
@Composable
fun PacketCard(address: String, packet: KTagPacket) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(
modifier = Modifier.padding(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = packet.typeName,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
when (packet) {
is InstigateGamePacket -> {
PacketRow("Game Length", "${packet.gameLength}ms")
PacketRow("Countdown", "${packet.countdownTime}ms")
}
is EventPacket -> {
PacketRow("Target", packet.targetAddress)
PacketRow("Event ID", packet.eventId.toString())
PacketRow("Event Data", packet.eventData.toString())
}
is TagPacket -> {
PacketRow("Team/Player", "${packet.teamId}/${packet.playerId}")
PacketRow("Damage", packet.damage.toString())
PacketRow("Protocol", packet.protocol.toString())
PacketRow("TX Power", "${packet.txPower}dBm")
PacketRow("Target", packet.targetAddress)
PacketRow("Color", String.format("#%08X", packet.color))
}
is ConsolePacket -> {
PacketRow("Message", packet.consoleString)
}
is StatusPacket -> {
PacketRow("Team/Player", "${packet.teamId}/${packet.playerId}")
PacketRow("Health", "${packet.health}/${packet.maxHealth}")
PacketRow("Protocol", packet.protocol.toString())
PacketRow("TX Power", "${packet.txPower}dBm")
PacketRow("Primary Color", String.format("#%08X", packet.primaryColor))
PacketRow("Secondary Color", String.format("#%08X", packet.secondaryColor))
PacketRow("State", packet.SystemKState.toString())
}
is ParametersPacket -> {
PacketRow("Target", packet.targetAddress)
PacketRow("Subtype", packet.subtype.toString())
PacketRow("Key 1", packet.key1.toString())
PacketRow("Value 1", packet.value1.toString())
PacketRow("Key 2", packet.key2.toString())
PacketRow("Value 2", packet.value2.toString())
}
is HelloPacket -> {
PacketRow("Version", String.format("SystemK v%d.%02d", packet.majorVersion, packet.minorVersion))
PacketRow("Device Type", Packet.getDeviceTypeName(packet.deviceType))
PacketRow("Team ID", packet.teamId.toString())
PacketRow("Device Name", packet.deviceName)
}
}
Text(
text = "Event #${packet.eventNumber}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End)
)
}
}
}
@Composable
private fun PacketRow(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall
)
}
}
// Data classes for packet parsing
sealed class KTagPacket {
abstract val type: Int
abstract val typeName: String
abstract val eventNumber: Int
}
data class InstigateGamePacket(
override val type: Int,
override val typeName: String = "Instigate Game",
override val eventNumber: Int,
val gameLength: Int,
val countdownTime: Int
) : KTagPacket()
data class EventPacket(
override val type: Int,
override val typeName: String = "Event",
override val eventNumber: Int,
val targetAddress: String,
val eventId: Int,
val eventData: Int
) : KTagPacket()
data class TagPacket(
override val type: Int,
override val typeName: String = "Tag",
override val eventNumber: Int,
val txPower: Int,
val protocol: Int,
val teamId: Int,
val playerId: Int,
val damage: Int,
val color: Int,
val targetAddress: String
) : KTagPacket()
data class ConsolePacket(
override val type: Int,
override val typeName: String = "Console",
override val eventNumber: Int,
val consoleString: String
) : KTagPacket()
data class StatusPacket(
override val type: Int,
override val typeName: String = "Status",
override val eventNumber: Int,
val txPower: Int,
val protocol: Int,
val teamId: Int,
val playerId: Int,
val health: Int,
val maxHealth: Int,
val primaryColor: Int,
val secondaryColor: Int,
val SystemKState: Int
) : KTagPacket()
data class ParametersPacket(
override val type: Int,
override val typeName: String = "Parameters",
override val eventNumber: Int,
val targetAddress: String,
val subtype: Int,
val key1: Int,
val value1: Int,
val key2: Int,
val value2: Int
) : KTagPacket()
data class HelloPacket(
override val type: Int,
override val typeName: String = "Hello",
override val eventNumber: Int,
val majorVersion: Int,
val minorVersion: Int,
val deviceType: Int,
val teamId: Int,
val deviceName: String
) : KTagPacket()
fun parseKTagPacket(data: ByteArray): KTagPacket {
val type = data[4].toInt()
val eventNumber = data[5].toInt()
return when (type) {
0x01 -> InstigateGamePacket(
type = type,
eventNumber = eventNumber,
gameLength = bytesToInt(data, 6, 4),
countdownTime = bytesToInt(data, 10, 4)
)
0x02 -> EventPacket(
type = type,
eventNumber = eventNumber,
targetAddress = bytesToMacAddress(data, 6),
eventId = bytesToInt(data, 12, 4),
eventData = bytesToInt(data, 16, 4)
)
0x03 -> TagPacket(
type = type,
eventNumber = eventNumber,
txPower = data[6].toInt(),
protocol = data[7].toInt(),
teamId = data[8].toInt(),
playerId = data[9].toInt(),
damage = bytesToInt(data, 10, 2),
color = bytesToInt(data, 12, 4),
targetAddress = bytesToMacAddress(data, 16)
)
0x04 -> ConsolePacket(
type = type,
eventNumber = eventNumber,
consoleString = data.slice(6..26).toByteArray().let { bytes ->
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
if (nullPos >= 0) {
bytes.slice(0 until nullPos).toByteArray()
} else {
bytes
}.toString(Charsets.US_ASCII).trim()
}
)
0x05 -> StatusPacket(
type = type,
eventNumber = eventNumber,
txPower = data[6].toInt(),
protocol = data[7].toInt(),
teamId = data[8].toInt(),
playerId = data[8].toInt(),
health = bytesToInt(data, 10, 2),
maxHealth = bytesToInt(data, 12, 2),
primaryColor = bytesToInt(data, 14, 4),
secondaryColor = bytesToInt(data, 18, 4),
SystemKState = data[22].toInt()
)
0x06 -> ParametersPacket(
type = type,
eventNumber = eventNumber,
targetAddress = bytesToMacAddress(data, 6),
subtype = data[12].toInt(),
key1 = bytesToInt(data, 13, 2),
value1 = bytesToInt(data, 15, 4),
key2 = bytesToInt(data, 19, 2),
value2 = bytesToInt(data, 21, 4)
)
0x07 -> HelloPacket(
type = type,
eventNumber = eventNumber,
majorVersion = data[6].toInt(),
minorVersion = data[7].toInt(),
deviceType = bytesToInt(data, 8, 2),
teamId = data[10].toInt(),
deviceName = data.slice(11..26).toByteArray().let { bytes ->
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
if (nullPos >= 0) {
bytes.slice(0 until nullPos).toByteArray()
} else {
bytes
}.toString(Charsets.US_ASCII).trim()
}
)
else -> StatusPacket(
type = type,
typeName = "Unknown",
eventNumber = eventNumber,
txPower = 0,
protocol = 0,
teamId = 0,
playerId = 0,
health = 0,
maxHealth = 0,
primaryColor = 0,
secondaryColor = 0,
SystemKState = 0
)
}
}
private fun bytesToInt(data: ByteArray, offset: Int, length: Int): Int {
var result = 0
for (i in 0 until length) {
result = result or ((data[offset + i].toInt() and 0xFF) shl (8 * i))
}
return result
}
private fun bytesToMacAddress(data: ByteArray, offset: Int): String {
return (0..5).joinToString(":") {
String.format("%02X", data[offset + it])
}
}

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View file

@ -0,0 +1,264 @@
# KTag Konfigurator Subapp
A Jetpack Compose Android application for configuring KTag laser tag devices via BLE and coordinating game sessions.
## Overview
The Konfigurator manages the full lifecycle of a KTag game session: configuring game parameters, discovering and assigning devices to teams, broadcasting game start/stop events, and running game timers. It communicates with KTag devices over BLE using the KTag 27-byte packet protocol.
## Architecture
The app follows the **MVVM (Model-View-ViewModel)** pattern with a state machine driving screen transitions.
```
┌─────────────────────────────────────────────────────────┐
│ KonfiguratorActivity │
│ (Compose Host + Permissions) │
└─────────────────────┬───────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────┐
│ StateMachineViewModel │
│ • State machine (6 screens) │
│ • BLE scanning & advertising │
│ • Multi-device configuration │
│ • Game config management │
└──────┬──────────────┬───────────────────────────────────┘
│ │
┌──────▼──────┐ ┌─────▼──────────────────────────────────┐
│ BleManager │ │ MultiDeviceConfigurator │
│ (Singleton) │ │ (Sequential BLE config with mutex) │
└─────────────┘ └────────────────────────────────────────┘
```
## File Structure
```
src/main/java/club/clubk/ktag/apps/konfigurator/
├── KonfiguratorActivity.kt # Compose activity with BLE permissions
├── StateMachineViewModel.kt # State machine, BLE orchestration
├── BleManager.kt # BLE scan/advertise singleton
├── MultiDeviceConfigurator.kt # Sequential multi-device configuration
├── KTagPacket.kt # Packet types, parsing, generators
├── Device.kt # Device data class
├── DeviceState.kt # Sealed class: Configurable/Ready/Playing/WrapUp
├── AppState.kt # Sealed class: 6 screen states
├── GameConfig.kt # Game parameters data class
├── ConfigurationProgress.kt # Configuration progress tracking
├── Player.kt # Player data class
├── SoundManager.kt # Audio feedback (SoundPool)
├── KonfiguratorSubApp.kt # Subapp registration
├── KonfiguratorInitializer.kt # androidx.startup initializer
└── ui/theme/
├── Theme.kt # Material3 dynamic theming
├── Color.kt # Color palette
└── Type.kt # Typography
```
## State Machine
```
┌──────────────────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ │
│ GameSettings │ Configure duration, health, rounds │
└───────┬───────┘ │
│ → start BLE scanning │
▼ │
┌───────────────┐ │
│ TeamSettings │ Discover devices, assign teams, apply config │
└───────┬───────┘ │
│ [all devices ready] │
│ → stop scanning │
│ → advertise InstigatingGame packet │
▼ │
┌───────────────┐ │
│ PregameTimer │ Countdown before game │
└───────┬───────┘ │
│ [timer expires] │
│ → stop advertising │
▼ │
┌───────────────┐ │
│ Countdown │ Countdown lights sequence (5+1 s) │
└───────┬───────┘ │
│ [lights out] │
▼ │
┌───────────────┐ │
│ GameTimer │ Active game countdown │
└───────┬───────┘ │
│ [timer expires / End Game] │
│ → advertise EVENT_GAME_OVER │
▼ │
┌───────────────┐ │
│ WrapUp │ → advertise EVENT_WRAPUP_COMPLETE ──────────────────┘
└───────────────┘
```
| State | Description |
|----------------|----------------------------------------------------------------------------|
| `GameSettings` | Configure game duration, health, and rounds |
| `TeamSettings` | Scan for BLE devices, assign teams (Purple/Red/Blue), apply config |
| `PregameTimer` | Pregame countdown; InstigatingGame packet keeps devices in sync |
| `Countdown` | Countdown lights-out sequence (6 s) |
| `GameTimer` | Active game countdown with pie-chart timer; early-end available |
| `WrapUp` | Game complete; broadcasts EVENT_WRAPUP_COMPLETE, returns to GameSettings |
## Key Components
### StateMachineViewModel
Manages game state and BLE operations:
- **State Machine**: Transitions between 6 screen states, plays audio on each transition
- **Device Discovery**: BLE scanning with KTag manufacturer data filter (0xFFFF)
- **Team Assignment**: Cycle devices through teams (0=Purple, 1=Red, 2=Blue)
- **Configuration**: Sends Parameters packets to assign team and health to each device
- **Game Control**: Broadcasts Instigate Game, Event (ready/game over/wrapup) packets
### BleManager
Singleton managing BLE operations:
- **Advertising**: Broadcasts KTag packets as manufacturer-specific data
- **Scanning**: Filters for KTag magic bytes with scan holdoff (5s between scans)
- **Device Configuration**: Advertise config packet, scan for ACK, with 5s timeout
- **Thread Safety**: Coroutine-based with managed scan start jobs
### MultiDeviceConfigurator
Sequential device configuration:
- **Mutex-Protected**: Ensures one BLE config operation at a time
- **Progress Callbacks**: Reports per-device success/failure and overall progress
- **Error Handling**: Catches exceptions per-device, continues with remaining devices
### KTagPacket
Packet system with sealed class hierarchy:
- 7 packet types: InstigateGame, Event, Tag, Console, Status, Parameters, Hello
- `byteArrayToKTagPacket()`: Deserializes raw bytes to typed packets
- `kTagPacketToByteArray()`: Serializes packets to BLE advertisement bytes
- Packet generators with auto-incrementing event counters
## Game Configuration
| Parameter | Default | Description |
|---------------------------|---------|---------------------------------|
| `gameDurationMin` | 10 | Game duration in minutes |
| `timeUntilCountdownS` | 30 | Pregame countdown in seconds |
| `numRounds` | 2 | Number of rounds |
| `maxHealth` | 10 | Maximum player health |
| `specialWeaponsOnReentry` | 1 | Special weapons on game reentry |
## How to Add a New Configurable Parameter to a Device
Each configurable parameter follows the same "set then confirm" pattern: the app holds a *desired* value and a *broadcasted* (device-confirmed) value. The accent bar and checkmark on a device card turn green only when every parameter's desired value matches its broadcasted value.
The steps below use `fooBar` / `broadcastedFooBar` as the example parameter name.
### 1. Add the protocol key — `core/.../Packet.java`
Parameter keys are defined in the [KTag BLE Protocol Specification](https://ktag.clubk.club/Technology/BLE/). Check there first — the key you need may already exist as a `PARAMETER_KEY_*` constant in `Packet.java`. If it does not, add it:
```java
public static final int PARAMETER_KEY_FOO_BAR = <key_id_from_spec>;
```
Each Parameters BLE packet carries two key-value slots. If both slots in the last packet are already occupied, the new parameter needs its own packet (see step 6).
### 2. Add fields to the device model — `Device.kt`
```kotlin
var fooBar: Int? = null,
var broadcastedFooBar: Int? = null
```
`fooBar` is the value the operator wants to send. `broadcastedFooBar` is the value last acknowledged by the device.
### 3. Register the parameter for match-checking — `Device.kt`
Add one line to `allSettingsMatch()`:
```kotlin
fun allSettingsMatch(): Boolean =
paramMatches(team, broadcastedTeam) &&
paramMatches(maxHealth, broadcastedMaxHealth) &&
paramMatches(specialWeaponsOnReentry, broadcastedSpecialWeaponsOnReentry) &&
paramMatches(fooBar, broadcastedFooBar) // ← add this
```
### 4. Register the parameter for configure confirmation — `StateMachineViewModel.kt`
In `configureDevices()`, two `confirmOnSuccess` lambdas are built in parallel with the packet list. Each lambda is applied to the device immediately when its specific packet is ACK'd. Add `broadcastedFooBar` to whichever lambda corresponds to the packet that carries `PARAMETER_KEY_FOO_BAR`:
```kotlin
confirmOnSuccess += { d -> d.copy(
broadcastedTeam = d.team ?: d.broadcastedTeam,
broadcastedMaxHealth = d.maxHealth ?: d.broadcastedMaxHealth,
broadcastedFooBar = d.fooBar ?: d.broadcastedFooBar // ← add this
)}
```
This ensures a failed packet never blocks confirmation of packets that succeeded.
### 5. Preserve both fields when refreshing a device — `StateMachineViewModel.kt`
`addOrRefreshDevice()` merges incoming scan data with the existing device record. Add two lines following the same pattern as the other parameters:
```kotlin
newDevice.fooBar = oldDevice.fooBar ?: newDevice.fooBar
newDevice.broadcastedFooBar = newDevice.broadcastedFooBar ?: oldDevice.broadcastedFooBar
```
### 6. Initialize and send the parameter — `StateMachineViewModel.kt`
**Initialize** `fooBar` when a Hello packet is received (typically from game config defaults, same as `maxHealth`):
```kotlin
scannedDevice.fooBar = _currentGameConfig.value.fooBar
```
**Send** it during configure. In `configureDevices()`, the `flatMap` builds one `Pair<String, Packet.Parameters>` per BLE packet. Each Parameters packet has two key-value slots. Either fill an empty slot in an existing packet or add a new one:
```kotlin
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
Packet.PARAMETER_KEY_FOO_BAR, device.fooBar ?: gameCfg.fooBar,
Packet.PARAMETER_KEY_NONE, 0))
```
If the device also broadcasts `fooBar` in its Status packets, parse it in the Status handler and set `scannedDevice.broadcastedFooBar` there (same as `broadcastedTeam`). This provides an ongoing truth-check from the device in addition to the immediate confirmation from the ACK packet.
### 7. Add an update function — `StateMachineViewModel.kt`
```kotlin
fun updateDeviceFooBar(deviceAddress: String, value: Int) =
updateDeviceField(deviceAddress) { it.copy(fooBar = value) }
```
`updateDeviceField` handles the `_devices` update and sets `_pendingApply = true` automatically.
### 8. Add a UI field to the device detail dialog — `KonfiguratorActivity.kt`
In `DeviceDetailDialog`, add a state variable and an `IntegerTextField`:
```kotlin
var fooBarText by remember { mutableStateOf(device.fooBar?.toString() ?: "") }
var fooBarError by remember { mutableStateOf(false) }
IntegerTextField(
value = fooBarText,
onValueChange = { fooBarText = it; fooBarError = false },
label = "Foo Bar",
isError = fooBarError
)
```
Update the `onSave` lambda signature to include the new value, validate it in the Save button, and call `stateMachine.updateDeviceFooBar(device.address, fooBar)` alongside the other update calls.
## Dependencies
- Jetpack Compose (Material3)
- ViewModel + Compose integration
- BLE (BluetoothLeScanner, BluetoothLeAdvertiser)
- SoundPool (audio feedback)

View file

@ -0,0 +1,36 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.konfigurator"
compileSdk = 36
defaultConfig { minSdk = 24 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
}
dependencies {
implementation(project(":core"))
implementation(project(":shared-services"))
implementation("androidx.preference:preference:1.2.1")
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)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,28 @@
<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=".KonfiguratorActivity"
android:exported="false"
android:label="Konfigurator" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="club.clubk.ktag.apps.konfigurator.KonfiguratorInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.apps.konfigurator
sealed class AppState {
object GameSettings : AppState()
object TeamSettings : AppState()
object PregameTimer : AppState()
object Countdown : AppState()
object GameTimer : AppState()
object WrapUp : AppState()
}

View file

@ -0,0 +1,336 @@
package club.clubk.ktag.apps.konfigurator
import android.annotation.SuppressLint
import android.content.Context
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.util.Log
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import club.clubk.ktag.apps.core.ble.Packet
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TAG_BLE_SCAN = "BLE Scanner"
private const val TAG_BLE_AD = "BLE Advertiser"
private const val SCAN_HOLDOFF_PERIOD_MS: Long = 5000 // 5 seconds
class BleManager private constructor(context: Context) {
// Use applicationContext to prevent memory leaks
private val appContext: Context = context.applicationContext
// Bluetooth-related properties
private val bluetoothManager: BluetoothManager =
appContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bluetoothLeAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
private var currentAdvertiseCallback: AdvertiseCallback? = null
private var currentScanCallback: ScanCallback? = null
private var isAdvertising: Boolean = false
private var isScanning: Boolean = false
// Timestamp for the last scan stop
@Volatile
private var lastScanStopTimeMs: Long = 0
// Coroutine scope for managing delays
private val bleManagerScope = CoroutineScope(Dispatchers.Main + Job()) // Use Main dispatcher for UI-related callbacks if needed, or Default for background work
private var scanStartJob: Job? = null
// Singleton instance
companion object {
@Volatile
private var instance: BleManager? = null
/**
* Gets or creates the BleManager instance
*
* @param context The context used to get the application context
* @return BleManager instance
*/
fun getInstance(context: Context): BleManager {
// Double-checked locking pattern
return instance ?: synchronized(this) {
instance ?: BleManager(context).also { instance = it }
}
}
}
// Utility function to check if Bluetooth is available and enabled
private fun isBluetoothAvailable(): Boolean {
return bluetoothAdapter != null && bluetoothAdapter.isEnabled
}
@SuppressLint("MissingPermission")
fun startAdvertising(data: ByteArray, advertiseCallback: AdvertiseCallback) {
stopAdvertising()
if (!isBluetoothAvailable()) {
Log.e(TAG_BLE_AD, "Bluetooth is not enabled!")
throw IllegalStateException("Bluetooth is not enabled!")
}
currentAdvertiseCallback = advertiseCallback
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_BLE_AD,
"Data exceeded maximum size (${data.size} > $maxManufacturerDataSize bytes), truncating..."
)
data.copyOfRange(0, maxManufacturerDataSize)
} else {
data
}
Log.d(TAG_BLE_AD, "Advertisement structure:")
Log.d(TAG_BLE_AD, "- Total payload max: 31 bytes")
Log.d(TAG_BLE_AD, "- Overhead: 4 bytes (Length: 1, Type: 1, Manufacturer ID: 2)")
Log.d(TAG_BLE_AD, "- Available for manufacturer data: $maxManufacturerDataSize bytes")
Log.d(TAG_BLE_AD, "- Actual data size: ${truncatedData.size} bytes")
Log.d(TAG_BLE_AD, "- Data: ${truncatedData.joinToString(" ") { String.format("%02X", it) }}")
val advertiseData = AdvertiseData.Builder()
.addManufacturerData(0xFFFF, truncatedData)
.build()
Log.i(
TAG_BLE_AD, "Starting advertisement with settings: Mode=${settings.mode}, " +
"TxPower=${settings.txPowerLevel}, Connectable=${settings.isConnectable}"
)
try {
bluetoothLeAdvertiser?.startAdvertising(settings, advertiseData, advertiseCallback)
isAdvertising = true
} catch (e: SecurityException) {
Log.e(TAG_BLE_AD, "Permission missing for starting advertisement", e)
advertiseCallback.onStartFailure(AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR) // Notify caller
}
}
@SuppressLint("MissingPermission")
fun stopAdvertising() {
Log.d(TAG_BLE_AD, "Attempting to stop advertising...")
currentAdvertiseCallback?.let { callback ->
try {
bluetoothLeAdvertiser?.stopAdvertising(callback)
Log.i(TAG_BLE_AD, "Advertisement stopping process initiated.")
} catch (e: SecurityException) {
Log.e(TAG_BLE_AD, "Permission missing for stopping advertisement", e)
} finally {
currentAdvertiseCallback = null // Clear callback regardless of success/failure to stop
isAdvertising = false
}
} ?: Log.d(TAG_BLE_AD, "No active advertisement to stop or advertiser is null.")
// isAdvertising should be reliably set to false when stop is called or if no active ad
if (currentAdvertiseCallback == null) isAdvertising = false
}
@SuppressLint("MissingPermission")
fun startScanning(scanCallback: ScanCallback) {
// Cancel any pending scan start operation
scanStartJob?.cancel()
scanStartJob = bleManagerScope.launch {
val currentTimeMs = System.currentTimeMillis()
val timeSinceLastScanStopMs = currentTimeMs - lastScanStopTimeMs
if (isScanning) { // If already scanning with a *different* callback, stop it first
Log.d(TAG_BLE_SCAN, "Already scanning, but a new scan request received. Stopping current scan first.")
internalStopScan() // Use an internal stop that doesn't update lastScanStopTimeMs yet
}
if (lastScanStopTimeMs > 0 && timeSinceLastScanStopMs < SCAN_HOLDOFF_PERIOD_MS) {
val delayNeededMs = SCAN_HOLDOFF_PERIOD_MS - timeSinceLastScanStopMs
Log.i(TAG_BLE_SCAN, "Scan holdoff active. Delaying scan start by ${delayNeededMs}ms")
delay(delayNeededMs)
}
// After potential delay, stop any existing scan (e.g. if one was running with a different callback)
// This also ensures that if stopScanning() was called very recently, we respect that.
if (currentScanCallback != null && currentScanCallback != scanCallback) {
internalStopScan() // Stop previous scan if a new one is requested with different callback
}
currentScanCallback = scanCallback // Set the new callback
val filter = ScanFilter.Builder()
.setManufacturerData(
0xFFFF, // Manufacturer ID
byteArrayOf(0x4B, 0x54, 0x61, 0x67) // "KTag"
)
.build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
if (!isBluetoothAvailable()) {
Log.e(TAG_BLE_SCAN, "Bluetooth is not enabled! Cannot start scan.")
scanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) // Notify caller
return@launch
}
Log.i(TAG_BLE_SCAN, "Starting scan...")
try {
bluetoothLeScanner?.startScan(listOf(filter), settings, scanCallback)
isScanning = true
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for starting scan", e)
scanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) // Notify caller
isScanning = false // Ensure state is correct
}
}
}
@SuppressLint("MissingPermission")
fun stopScanning() {
// Cancel any pending scan start job, as we are now explicitly stopping.
scanStartJob?.cancel()
internalStopScan()
}
// Internal stop scan function to avoid recursive calls and manage state correctly
@SuppressLint("MissingPermission")
private fun internalStopScan() {
Log.d(TAG_BLE_SCAN, "Attempting to stop scanning (internal)...")
currentScanCallback?.let { callback ->
try {
bluetoothLeScanner?.stopScan(callback)
Log.i(TAG_BLE_SCAN, "Scan stopping process initiated.")
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for stopping scan", e)
} finally {
// This is a good place to record the stop time
lastScanStopTimeMs = System.currentTimeMillis()
currentScanCallback = null // Clear callback
isScanning = false // Update scanning state
}
} ?: Log.d(TAG_BLE_SCAN, "No active scan to stop or scanner is null.")
// Ensure scanning state is false if no callback
if (currentScanCallback == null) isScanning = false
}
@SuppressLint("MissingPermission")
suspend fun configureDevice(configPacket: Packet.Parameters, targetAddress: String): Boolean {
// Ensure configureDevice uses the managed startScanning/stopScanning
return withTimeout(5000) { // Timeout for the whole configuration operation
val advertisementDetected = CompletableDeferred<Boolean>()
var tempAdvertiseCallback: AdvertiseCallback? = null
var tempScanCallback: ScanCallback? = null
try {
tempAdvertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
Log.d(TAG_BLE_AD,"Config advertisement started successfully.")
// Only start scanning AFTER advertisement has successfully started
tempScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
Log.d(TAG_BLE_SCAN, ">>> ScanCallback.onScanResult ENTERED")
if (Packet.IsKTagParametersPacket(result)) {
val scannedParams = Packet.Parameters(result)
Log.d(TAG_BLE_SCAN, "Parameter packet scanned, checking for parameter match...")
if (result.device.address == targetAddress
&& scannedParams.subtype == Packet.PARAMETER_SUBTYPE_ACKNOWLEDGE_CHANGE
&& scannedParams.key1 == configPacket.key1
&& scannedParams.value1 == configPacket.value1
&& scannedParams.key2 == configPacket.key2
&& scannedParams.value2 == configPacket.value2
) {
Log.i(TAG_BLE_SCAN, "Parameters match, configuration successful for ${result.device.address}!")
if (!advertisementDetected.isCompleted) {
advertisementDetected.complete(true)
}
}
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG_BLE_SCAN, "Config scan failed with error: $errorCode")
if (!advertisementDetected.isCompleted) {
advertisementDetected.complete(false)
}
}
}
// Stop any in-progress regular scan, then start the ACK scan
// directly — bypassing startScanning's holdoff, which is the same
// duration as the configure timeout and would always cause a miss.
scanStartJob?.cancel()
internalStopScan()
val ackFilter = ScanFilter.Builder()
.setManufacturerData(0xFFFF, byteArrayOf(0x4B, 0x54, 0x61, 0x67))
.build()
val ackSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
try {
bluetoothLeScanner?.startScan(listOf(ackFilter), ackSettings, tempScanCallback!!)
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for ACK scan", e)
if (!advertisementDetected.isCompleted) advertisementDetected.complete(false)
}
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
Log.e(TAG_BLE_AD, "Failed to start config advertisement, error: $errorCode")
if (!advertisementDetected.isCompleted) {
advertisementDetected.complete(false)
}
}
}
Log.d(TAG_BLE_AD, "Sending configuration packet to $targetAddress...")
this@BleManager.startAdvertising(configPacket.GetBytes(),
tempAdvertiseCallback
)
val success = advertisementDetected.await() // Wait for scan result or ad failure
success
} catch (e: TimeoutCancellationException) {
Log.e(TAG_BLE_SCAN, "Config confirmation timed out for $targetAddress")
false
} finally {
Log.d(TAG_BLE_SCAN, "Cleaning up configureDevice resources for $targetAddress")
// Stop the specific advertising and scanning session for this configuration attempt
// Check callbacks before stopping to ensure they were set
tempAdvertiseCallback?.let { this@BleManager.stopAdvertising() }
// Stop the ACK scan directly so we don't stamp a new holdoff timestamp
// between consecutive configure packets.
tempScanCallback?.let {
try {
bluetoothLeScanner?.stopScan(it)
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for stopping ACK scan", e)
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more