Initial public release.
42
.gitignore
vendored
Normal 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
|
||||
4
LICENSE
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
40
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
303
app/src/main/java/club/clubk/ktag/apps/MainActivity.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/club/clubk/ktag/apps/ui/theme/Color.kt
Normal 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
|
||||
52
app/src/main/java/club/clubk/ktag/apps/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
47
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">KTag Apps</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KTagApps" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
17
app/src/main/res/xml/ble_settings_pref.xml
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
2
core/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
73
core/src/main/java/club/clubk/ktag/apps/core/DeviceModel.kt
Normal 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
|
||||
)
|
||||
60
core/src/main/java/club/clubk/ktag/apps/core/HexUtils.java
Normal 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);
|
||||
}
|
||||
}
|
||||
12
core/src/main/java/club/clubk/ktag/apps/core/SubApp.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
241
core/src/main/java/club/clubk/ktag/apps/core/UsbSerialManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
612
core/src/main/java/club/clubk/ktag/apps/core/ble/Packet.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
BIN
core/src/main/res/drawable/ktag_shield.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
core/src/main/res/drawable/ktag_shield_gray.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
8
gradle.properties
Normal 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
|
||||
13
gradle/gradle-daemon-jvm.properties
Normal 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
|
|
@ -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" }
|
||||
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
58
mqtt-broker/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
25
mqtt-broker/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
111
mqtt-broker/src/main/res/xml/broker_settings_pref.xml
Normal 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
|
|
@ -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")
|
||||
53
shared-services/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
4
shared-services/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
6
shared-services/src/main/res/values/attrs.xml
Normal 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
|
|
@ -0,0 +1,120 @@
|
|||
# KTag BLE Tool Subapp
|
||||
|
||||
A Jetpack Compose Android application for testing and debugging KTag BLE protocol packets.
|
||||
|
||||
## Overview
|
||||
|
||||
The BLE Tool provides two main functions:
|
||||
|
||||
1. **Advertiser**: Craft and broadcast custom KTag BLE advertisement packets
|
||||
2. **Scanner**: Scan for and decode KTag devices broadcasting status packets
|
||||
|
||||
This tool is essential for development, testing, and debugging KTag devices and the protocol specification.
|
||||
|
||||
## Architecture
|
||||
|
||||
The app uses a simple activity-based architecture with Compose UI.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ BleToolActivity │
|
||||
│ (Advertiser Tab - Main) │
|
||||
│ • Byte grid editor for packet construction │
|
||||
│ • Preset packet templates │
|
||||
│ • Start/Stop broadcasting │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ScannerActivity │
|
||||
│ (Scanner Tab) │
|
||||
│ • BLE scanning for KTag packets │
|
||||
│ • Real-time packet decoding │
|
||||
│ • Device list with parsed fields │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/main/java/club/clubk/ktag/apps/bletool/
|
||||
├── BleToolActivity.kt # Main advertiser screen
|
||||
├── ScannerActivity.kt # Scanner screen + packet parsing
|
||||
├── AdvertisementPreset.kt # Preset packet definitions
|
||||
├── ByteCellData.kt # Byte cell display data
|
||||
├── PacketFieldUtils.kt # Field descriptions & colors
|
||||
├── BleToolSubApp.kt # Subapp registration
|
||||
├── BleToolInitializer.kt
|
||||
└── ui/ # (Composables in main files)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Advertiser
|
||||
|
||||
- **31-byte packet editor**: Visual grid showing all packet bytes
|
||||
- **Header protection**: First 8 bytes (KTag header) are read-only
|
||||
- **Hex editing**: Tap any byte to edit its value
|
||||
- **Field highlighting**: Color-coded fields based on packet type
|
||||
- **Presets**: Quick-load common packet types:
|
||||
- Instigate Game (0x01)
|
||||
- Event (0x02)
|
||||
- Tag (0x03)
|
||||
- Console (0x04)
|
||||
- Status (0x05)
|
||||
- Parameters (0x06)
|
||||
- Hello (0x07)
|
||||
|
||||
### Scanner
|
||||
|
||||
- **KTag filtering**: Only shows devices with valid KTag packets
|
||||
- **Real-time updates**: Live packet decoding as devices broadcast
|
||||
- **Full packet parsing**: Decodes all 7 packet types with field details
|
||||
- **Device tracking**: Groups packets by device address
|
||||
|
||||
## Packet Types
|
||||
|
||||
| Type | Name | Key Fields |
|
||||
|------|------|------------|
|
||||
| 0x01 | Instigate Game | Game length, Countdown time |
|
||||
| 0x02 | Event | Target address, Event ID/Data |
|
||||
| 0x03 | Tag | Team, Player, Damage, Color, Target |
|
||||
| 0x04 | Console | ASCII message string |
|
||||
| 0x05 | Status | Team, Health, Colors, State |
|
||||
| 0x06 | Parameters | Target, Key/Value pairs |
|
||||
| 0x07 | Hello | Version, Device type, Name |
|
||||
|
||||
## KTag Packet Structure
|
||||
|
||||
All KTag packets follow this structure:
|
||||
|
||||
```
|
||||
Byte 0-3: "KTag" magic bytes (0x4B 0x54 0x61 0x67)
|
||||
Byte 4: Packet type (0x01-0x07)
|
||||
Byte 5: Event number (incrementing)
|
||||
Byte 6-26: Type-specific payload
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### BleAdvertiserScreen
|
||||
|
||||
Interactive packet editor with:
|
||||
|
||||
- `LazyVerticalGrid` of `ByteCell` components
|
||||
- `ExposedDropdownMenu` for preset selection
|
||||
- `ByteEditor` for hex value input
|
||||
- Start/Stop broadcast button
|
||||
|
||||
### ScannerScreen
|
||||
|
||||
Device scanner with:
|
||||
|
||||
- `LazyColumn` of `PacketCard` components
|
||||
- Sealed class hierarchy for packet types
|
||||
- `parseKTagPacket()` for decoding raw bytes
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Jetpack Compose (Material3)
|
||||
- BLE (BluetoothLeScanner, BluetoothLeAdvertiser)
|
||||
- Material Icons
|
||||
41
subapp-bletool/build.gradle.kts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "club.clubk.ktag.apps.bletool"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.startup)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
37
subapp-bletool/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="true" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".BleToolActivity"
|
||||
android:exported="false"
|
||||
android:label="KTag BLE Tool" />
|
||||
|
||||
<activity
|
||||
android:name=".ScannerActivity"
|
||||
android:exported="false"
|
||||
android:label="KTag Scanner" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="club.clubk.ktag.apps.bletool.BleToolInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
data class AdvertisementPreset(
|
||||
val name: String,
|
||||
val data: ByteArray,
|
||||
val description: String
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as AdvertisementPreset
|
||||
return name == other.name && data.contentEquals(other.data)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + data.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,757 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.AdvertiseCallback
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.Icon
|
||||
import android.content.Intent
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Scaffold
|
||||
import club.clubk.ktag.apps.bletool.ui.theme.KTagBLEToolTheme
|
||||
|
||||
private const val TAG = "BLE Tool"
|
||||
|
||||
val advertisementPresets = listOf(
|
||||
AdvertisementPreset(
|
||||
"01 Instigate Game",
|
||||
byteArrayOf(
|
||||
0x01.toByte(), // Packet Type: Instigate Game
|
||||
0x00.toByte(), // Event Number
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Game length (ms)
|
||||
0x88.toByte(),
|
||||
0x13.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Time remaining until Countdown (ms)s
|
||||
),
|
||||
"KTag Instigate Game Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"02 Event",
|
||||
byteArrayOf(
|
||||
0x02.toByte(), // Packet Type: Event Game
|
||||
0x00.toByte(), // Event Number
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(), // Target Bluetooth Device Address
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Event ID
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Event Data
|
||||
),
|
||||
"KTag Event Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"03 Tag",
|
||||
byteArrayOf(
|
||||
0x03.toByte(), // Packet Type: Tag
|
||||
0x00.toByte(), // Event Number
|
||||
0x00.toByte(), // Tx Power level (dBm)
|
||||
0x00.toByte(), // Protocol
|
||||
0x00.toByte(), // Team ID
|
||||
0x00.toByte(), // Player ID
|
||||
0x0A.toByte(),
|
||||
0x00.toByte(), // Damage
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Color
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(), // Target Bluetooth Device Address
|
||||
),
|
||||
"KTag Tag Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"04 Console",
|
||||
byteArrayOf(
|
||||
0x04.toByte(), // Packet Type: Console
|
||||
0x00.toByte(), // Event Number
|
||||
),
|
||||
"KTag Console Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"05 Status",
|
||||
byteArrayOf(
|
||||
0x05.toByte(), // Packet Type: Status
|
||||
0x00.toByte(), // Event Number
|
||||
0x00.toByte(), // Tx Power level (dBm)
|
||||
0x00.toByte(), // Protocol
|
||||
0x02.toByte(), // Team ID
|
||||
0x00.toByte(), // Player ID
|
||||
0x64.toByte(), 0x00.toByte(), // Health
|
||||
0x64.toByte(), 0x00.toByte(), // Maximum Health
|
||||
0xFE.toByte(), 0x00.toByte(), 0x00.toByte(), 0xFF.toByte(), // Primary Color
|
||||
0xFE.toByte(), 0xFF.toByte(), 0x00.toByte(), 0x00.toByte(), // Secondary Color
|
||||
0x07.toByte(), // SystemK Top-Level State
|
||||
),
|
||||
"KTag Status Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"06 Parameters",
|
||||
byteArrayOf(
|
||||
0x06.toByte(), // Packet Type: Parameters
|
||||
0x00.toByte(), // Event Number
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(), // Target Bluetooth Device Address
|
||||
0x02.toByte(), // Subtype: Request Parameter Change
|
||||
0x01.toByte(),
|
||||
0x00.toByte(), // Key 1: Team ID
|
||||
0x02.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Value 1: 2
|
||||
0xFF.toByte(),
|
||||
0xFF.toByte(), // Key 2: Unused
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(),
|
||||
0x00.toByte(), // Value 2: Unused
|
||||
),
|
||||
"KTag Parameters Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"07 Hello",
|
||||
byteArrayOf(
|
||||
0x07.toByte(), // Packet Type: Hello
|
||||
0x00.toByte(), // Event Number
|
||||
0x01.toByte(), // SystemK Major Version
|
||||
0x00.toByte(), // SystemK Minor Version
|
||||
0x02.toByte(), //
|
||||
0x00.toByte(), // Device Type: Mobile App
|
||||
0x02.toByte(), // Team ID
|
||||
0x4B.toByte(), // 'K'
|
||||
0x54.toByte(), // 'T'
|
||||
0x61.toByte(), // 'a'
|
||||
0x67.toByte(), // 'g'
|
||||
0x20.toByte(), // ' '
|
||||
0x42.toByte(), // 'B'
|
||||
0x4C.toByte(), // 'L'
|
||||
0x45.toByte(), // 'E'
|
||||
0x20.toByte(), // ' '
|
||||
0x54.toByte(), // 'T'
|
||||
0x6F.toByte(), // 'o'
|
||||
0x6F.toByte(), // 'o'
|
||||
0x6C.toByte(), // 'l'
|
||||
0x00.toByte(), // Device Name: "KTag BLE Tool"
|
||||
),
|
||||
"KTag Hello Packet"
|
||||
),
|
||||
AdvertisementPreset(
|
||||
"Empty",
|
||||
ByteArray(23) { 0 },
|
||||
"All zeros (clear current data)"
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Send, "Advertiser") },
|
||||
label = { Text("Advertise") },
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Search, "Scanner") },
|
||||
label = { Text("Scan") },
|
||||
selected = selectedTab == 1,
|
||||
onClick = {
|
||||
context.startActivity(Intent(context, ScannerActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
BleAdvertiserScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BleToolActivity : ComponentActivity() {
|
||||
private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH,
|
||||
Manifest.permission.BLUETOOTH_ADMIN,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val allGranted = permissions.entries.all { it.value }
|
||||
Log.d(TAG, "Permission results: ${permissions.map { "${it.key}: ${it.value}" }}")
|
||||
|
||||
if (allGranted) {
|
||||
Log.i(TAG, "All permissions granted")
|
||||
setContent {
|
||||
KTagBLEToolTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val deniedPermissions = permissions.filter { !it.value }.keys
|
||||
Log.w(TAG, "Some permissions denied: $deniedPermissions")
|
||||
setContent {
|
||||
KTagBLEToolTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
PermissionDeniedScreen(
|
||||
deniedPermissions = deniedPermissions.toList()
|
||||
) { requestPermissions() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(TAG, "onCreate called")
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private fun checkAndRequestPermissions() {
|
||||
Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
|
||||
if (hasRequiredPermissions()) {
|
||||
Log.i(TAG, "All required permissions already granted")
|
||||
setContent {
|
||||
KTagBLEToolTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Requesting permissions")
|
||||
requestPermissions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRequiredPermissions(): Boolean {
|
||||
return requiredPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
Log.d(TAG, "Launching permission request")
|
||||
permissionLauncher.launch(requiredPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionDeniedScreen(
|
||||
deniedPermissions: List<String>,
|
||||
onRequestPermissions: () -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "The following permissions are required:",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
deniedPermissions.forEach { permission ->
|
||||
Text(
|
||||
text = "• ${permission.split(".").last()}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d(TAG, "Permission request button clicked")
|
||||
onRequestPermissions()
|
||||
},
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
Text("Grant Permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val N_HEADER_BYTES = 8
|
||||
|
||||
@Composable
|
||||
fun TitleBox() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.border(2.dp, MaterialTheme.colorScheme.primary)
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "KTag BLE Tool",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "based on the specification 0.12",
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 16.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BleAdvertiserScreen() {
|
||||
Log.d(TAG, "Initializing BLE Advertiser screen")
|
||||
val context = LocalContext.current
|
||||
var advertisementData by remember {
|
||||
mutableStateOf(ByteArray(31) { 0 }.apply {
|
||||
this[0] = 0x1E.toByte() // Length (30 bytes of data follow)
|
||||
this[1] = 0xFF.toByte() // Type (Manufacturer Specific Data)
|
||||
this[2] = 0xFF.toByte() // Manufacturer ID (Other) - First byte
|
||||
this[3] = 0xFF.toByte() // Manufacturer ID (Other) - Second byte
|
||||
this[4] = 0x4B.toByte() // KTag Packet Indicator - 'K'
|
||||
this[5] = 0x54.toByte() // KTag Packet Indicator - 'T'
|
||||
this[6] = 0x61.toByte() // KTag Packet Indicator - 'a'
|
||||
this[7] = 0x67.toByte() // KTag Packet Indicator - 'g'
|
||||
})
|
||||
}
|
||||
var isAdvertising by remember { mutableStateOf(false) }
|
||||
var selectedByteIndex by remember { mutableIntStateOf(-1) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var expandedDropdown by remember { mutableStateOf(false) }
|
||||
var selectedPreset by remember { mutableStateOf<AdvertisementPreset?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
TitleBox()
|
||||
|
||||
val byteCells = remember(advertisementData) {
|
||||
val packetType = advertisementData[8]
|
||||
advertisementData.mapIndexed { index, byte ->
|
||||
ByteCellData(
|
||||
value = byte,
|
||||
isHeader = index < N_HEADER_BYTES,
|
||||
description = PacketFieldUtils.getFieldDescription(packetType, index),
|
||||
backgroundColor = PacketFieldUtils.getFieldColor(packetType, index)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
errorMessage?.let { message ->
|
||||
Log.e(TAG, "Showing error message: $message")
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(8),
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
) {
|
||||
items(byteCells.size) { index ->
|
||||
ByteCell(
|
||||
data = byteCells[index],
|
||||
isSelected = selectedByteIndex == index,
|
||||
onClick = {
|
||||
if (!byteCells[index].isHeader) {
|
||||
Log.d(TAG, "Selected byte at index $index")
|
||||
selectedByteIndex = index
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedDropdown,
|
||||
onExpandedChange = { expandedDropdown = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedPreset?.name ?: "Select Preset",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedDropdown,
|
||||
onDismissRequest = { expandedDropdown = false }
|
||||
) {
|
||||
advertisementPresets.forEach { preset ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(preset.name)
|
||||
Text(
|
||||
preset.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
Log.d(TAG, "Selected preset: ${preset.name}")
|
||||
selectedPreset = preset
|
||||
advertisementData = advertisementData.copyOf().also {
|
||||
preset.data.copyInto(it, destinationOffset = 8)
|
||||
}
|
||||
expandedDropdown = false
|
||||
selectedByteIndex = -1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedByteIndex >= N_HEADER_BYTES) {
|
||||
ByteEditor(
|
||||
currentValue = advertisementData[selectedByteIndex],
|
||||
onValueChange = { newValue ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"Updating byte at index $selectedByteIndex to ${
|
||||
String.format(
|
||||
"%02X",
|
||||
newValue
|
||||
)
|
||||
}"
|
||||
)
|
||||
advertisementData = advertisementData.copyOf().also {
|
||||
it[selectedByteIndex] = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
if (isAdvertising) {
|
||||
Log.i(TAG, "Stopping advertisement")
|
||||
stopAdvertising(context)
|
||||
} else {
|
||||
val payloadData =
|
||||
advertisementData.copyOfRange(4, advertisementData.size)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Starting advertisement with data: ${
|
||||
advertisementData.joinToString {
|
||||
String.format(
|
||||
"%02X",
|
||||
it
|
||||
)
|
||||
}
|
||||
}"
|
||||
)
|
||||
startAdvertising(context, payloadData)
|
||||
}
|
||||
isAdvertising = !isAdvertising
|
||||
errorMessage = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during advertisement operation", e)
|
||||
errorMessage = "Error: ${e.message}"
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(if (isAdvertising) "Stop Broadcasting" else "Start Broadcasting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ByteCell(
|
||||
data: ByteCellData,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val backgroundColor = when {
|
||||
data.isHeader -> MaterialTheme.colorScheme.secondaryContainer
|
||||
isSelected -> MaterialTheme.colorScheme.primary
|
||||
data.backgroundColor != null -> data.backgroundColor
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
|
||||
Column {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(N_HEADER_BYTES.dp)
|
||||
.size(48.dp),
|
||||
border = if (isSelected && !data.isHeader)
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
else null,
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||
onClick = onClick
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%02X", data.value),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
if (data.description != "") {
|
||||
Text(
|
||||
text = data.description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = N_HEADER_BYTES.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ByteEditor(
|
||||
currentValue: Byte,
|
||||
onValueChange: (Byte) -> Unit
|
||||
) {
|
||||
var textValue by remember(currentValue) {
|
||||
mutableStateOf(String.format("%02X", currentValue))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textValue,
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.length <= 2 && newValue.all { it.isDigit() || it in 'A'..'F' || it in 'a'..'f' }) {
|
||||
textValue = newValue.uppercase()
|
||||
if (newValue.length == 2) {
|
||||
onValueChange(newValue.toInt(16).toByte())
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.width(96.dp),
|
||||
label = { Text("Byte Value (Hex)") },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startAdvertising(context: Context, data: ByteArray) {
|
||||
Log.d(TAG, "Attempting to start advertising")
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val bluetoothAdapter = bluetoothManager.adapter ?: run {
|
||||
Log.e(TAG, "Bluetooth not supported on this device")
|
||||
throw IllegalStateException("Bluetooth not supported")
|
||||
}
|
||||
|
||||
if (!bluetoothAdapter.isEnabled) {
|
||||
Log.e(TAG, "Bluetooth is not enabled")
|
||||
throw IllegalStateException("Bluetooth is not enabled")
|
||||
}
|
||||
|
||||
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser ?: run {
|
||||
Log.e(TAG, "BLE advertising not supported on this device")
|
||||
throw IllegalStateException("BLE advertising not supported")
|
||||
}
|
||||
|
||||
val settings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setConnectable(false)
|
||||
.setTimeout(0)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.build()
|
||||
|
||||
val maxManufacturerDataSize = 27 // 31 - 4 bytes overhead
|
||||
|
||||
val truncatedData = if (data.size > maxManufacturerDataSize) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Data exceeded maximum size (${data.size} > $maxManufacturerDataSize bytes), truncating"
|
||||
)
|
||||
data.copyOfRange(0, maxManufacturerDataSize)
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
Log.d(TAG, "Advertisement structure:")
|
||||
Log.d(TAG, "- Total payload max: 31 bytes")
|
||||
Log.d(TAG, "- Overhead: 4 bytes (Length: 1, Type: 1, Manufacturer ID: 2)")
|
||||
Log.d(TAG, "- Available for manufacturer data: $maxManufacturerDataSize bytes")
|
||||
Log.d(TAG, "- Actual data size: ${truncatedData.size} bytes")
|
||||
Log.d(TAG, "- Data: ${truncatedData.joinToString(" ") { String.format("%02X", it) }}")
|
||||
|
||||
val advertiseData = AdvertiseData.Builder()
|
||||
.addManufacturerData(0xFFFF, truncatedData)
|
||||
.build()
|
||||
|
||||
Log.i(
|
||||
TAG, "Starting advertisement with settings: Mode=${settings.mode}, " +
|
||||
"TxPower=${settings.txPowerLevel}, Connectable=${settings.isConnectable}"
|
||||
)
|
||||
|
||||
advertiser.startAdvertising(settings, advertiseData, advertisingCallback)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun stopAdvertising(context: Context) {
|
||||
Log.d(TAG, "Attempting to stop advertising")
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val bluetoothAdapter = bluetoothManager.adapter
|
||||
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||
|
||||
advertiser?.let {
|
||||
Log.i(TAG, "Stopping advertisement")
|
||||
it.stopAdvertising(advertisingCallback)
|
||||
} ?: Log.w(TAG, "Cannot stop advertising - advertiser is null")
|
||||
}
|
||||
|
||||
private val advertisingCallback = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
super.onStartSuccess(settingsInEffect)
|
||||
Log.i(
|
||||
TAG, "Advertisement started successfully with settings: " +
|
||||
"Mode=${settingsInEffect.mode}, TxPower=${settingsInEffect.txPowerLevel}"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
super.onStartFailure(errorCode)
|
||||
val errorMessage = when (errorCode) {
|
||||
ADVERTISE_FAILED_ALREADY_STARTED -> "Already started"
|
||||
ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large"
|
||||
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
|
||||
ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error"
|
||||
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
|
||||
else -> "Unknown error $errorCode"
|
||||
}
|
||||
Log.e(TAG, "Failed to start advertising: $errorMessage")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import club.clubk.ktag.apps.core.SubAppRegistry
|
||||
|
||||
class BleToolInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
SubAppRegistry.register(BleToolSubApp())
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.core.SubApp
|
||||
|
||||
class BleToolSubApp : SubApp {
|
||||
override val id = "bletool"
|
||||
override val name = "BLE Tool"
|
||||
override val icon = R.drawable.ic_bletool
|
||||
|
||||
override fun createIntent(context: Context): Intent {
|
||||
return Intent(context, BleToolActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
data class ByteCellData(
|
||||
val value: Byte,
|
||||
val isHeader: Boolean,
|
||||
val description: String,
|
||||
val backgroundColor: Color? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
// Pastel versions of KTag colors for field highlighting
|
||||
private val LightBlue = KTagBlue.copy(alpha = 0.25f)
|
||||
private val LightGreen = KTagGreen.copy(alpha = 0.25f)
|
||||
private val LightRed = KTagRed.copy(alpha = 0.25f)
|
||||
private val LightYellow = KTagYellow.copy(alpha = 0.25f)
|
||||
private val LightPurple = KTagPurple.copy(alpha = 0.25f)
|
||||
private val LightGray = Color(0xFFF5F5F5)
|
||||
|
||||
object PacketFieldUtils {
|
||||
fun getFieldDescription(packetType: Byte, index: Int): String {
|
||||
return when (index) {
|
||||
0 -> "Length" // Always "Length"
|
||||
1 -> "Type" // Always "Type"
|
||||
2 -> "Mfg ID" // Always "Mfg ID"
|
||||
3 -> "Mfg ID" // Always "Mfg ID"
|
||||
4 -> "'K'" // Always "'K'"
|
||||
5 -> "'T'" // Always "'T'"
|
||||
6 -> "'a'" // Always "'a'"
|
||||
7 -> "'g'" // Always "'g'"
|
||||
8 -> when (packetType) {
|
||||
0x01.toByte() -> "Instigate Game"
|
||||
0x02.toByte() -> "Event"
|
||||
0x03.toByte() -> "Tag"
|
||||
0x04.toByte() -> "Console"
|
||||
0x05.toByte() -> "Status"
|
||||
0x06.toByte() -> "Parameters"
|
||||
0x07.toByte() -> "Hello"
|
||||
else -> "Packet Type"
|
||||
}
|
||||
|
||||
9 -> "Event Number"
|
||||
else -> when (packetType) {
|
||||
0x01.toByte() -> when (index) { // Instigate Game packet fields
|
||||
in 10..13 -> "Game Length (ms)"
|
||||
in 14..17 -> "Time till Countdown (ms)"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
0x02.toByte() -> when (index) { // Event packet fields
|
||||
in 10..15 -> "Target Address"
|
||||
in 16..19 -> "Event ID"
|
||||
in 20..23 -> "Event Data"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
0x03.toByte() -> when (index) { // Tag packet fields
|
||||
10 -> "Tx Pwr (dBm)"
|
||||
11 -> "Protocol"
|
||||
12 -> "Team ID"
|
||||
13 -> "Player ID"
|
||||
14, 15 -> "Damage"
|
||||
in 16..19 -> "Color"
|
||||
in 20..25 -> "Target Address"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
0x04.toByte() -> when (index) { // Console packet fields
|
||||
in 10..30 -> "Console String"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
0x05.toByte() -> when (index) { // Status packet fields
|
||||
10 -> "Tx Pwr (dBm)"
|
||||
11 -> "Protocol"
|
||||
12 -> "Team ID"
|
||||
13 -> "Player ID"
|
||||
14, 15 -> "Health"
|
||||
16, 17 -> "Max Health"
|
||||
in 18..21 -> "Primary Color"
|
||||
in 22..25 -> "Secondary Color"
|
||||
26 -> "SystemK State"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
0x06.toByte() -> when (index) { // Parameters packet fields
|
||||
in 10..15 -> "Target Address"
|
||||
16 -> "Subtype"
|
||||
17, 18 -> "Key 1"
|
||||
in 19..22 -> "Value 1"
|
||||
23, 24 -> "Key 2"
|
||||
in 25..28 -> "Value 2"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
0x07.toByte() -> when (index) { // Hello packet fields
|
||||
10 -> "SystemK Major Version"
|
||||
11 -> "SystemK Minor Version"
|
||||
12, 13 -> "Device Type"
|
||||
14 -> "Team ID"
|
||||
in 15..30 -> "Device Name"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFieldColor(packetType: Byte, index: Int): Color? {
|
||||
// Header bytes always return null to use default header color
|
||||
if (index < 8) return null
|
||||
|
||||
// Packet type byte is always a distinct color
|
||||
if (index == 8) return LightBlue
|
||||
|
||||
// Event number is always the same color across all packet types
|
||||
if (index == 9) return LightGreen
|
||||
|
||||
return when (packetType) {
|
||||
0x01.toByte() -> when (index) { // Instigate Game packet
|
||||
in 10..13 -> LightYellow // Game Length
|
||||
in 14..17 -> LightGreen // Time until Countdown
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
0x02.toByte() -> when (index) { // Event packet
|
||||
in 10..15 -> LightBlue // Target Address
|
||||
in 16..19 -> LightPurple // Event ID
|
||||
in 20..23 -> LightGreen // Event Data
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
0x03.toByte() -> when (index) { // Tag packet
|
||||
10 -> LightYellow // Tx Power
|
||||
11 -> LightGreen // Protocol
|
||||
12 -> LightRed // Team ID
|
||||
13 -> LightPurple // Player ID
|
||||
in 14..15 -> LightRed // Damage
|
||||
in 16..19 -> LightGreen // Color
|
||||
in 20..25 -> LightBlue // Target Address
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
0x04.toByte() -> when (index) { // Console packet
|
||||
in 10..30 -> LightYellow // Console String
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
0x05.toByte() -> when (index) { // Status packet
|
||||
10 -> LightYellow // Tx Power
|
||||
11 -> LightGreen // Protocol
|
||||
12 -> LightRed // Team ID
|
||||
13 -> LightPurple // Player ID
|
||||
14, 15 -> LightRed // Health
|
||||
16, 17 -> LightGreen // Maximum Health
|
||||
in 18..21 -> LightBlue // Primary Color
|
||||
in 22..25 -> LightPurple // Secondary Color
|
||||
26 -> LightYellow // SystemK State
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
0x06.toByte() -> when (index) { // Parameters packet
|
||||
in 10..15 -> LightBlue // Target Address
|
||||
16 -> LightYellow // Subtype
|
||||
17, 18 -> LightPurple // Key 1
|
||||
in 19..22 -> LightGreen // Value 1
|
||||
23, 24 -> LightPurple // Key 2
|
||||
in 25..28 -> LightGreen // Value 2
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
0x07.toByte() -> when (index) { // Hello packet
|
||||
10 -> LightYellow // SystemK Major Version
|
||||
11 -> LightGreen // SystemK Minor Version
|
||||
12, 13 -> LightBlue // Device Type
|
||||
14 -> LightRed // Team ID
|
||||
in 15..30 -> LightPurple // Device Name
|
||||
else -> LightGray
|
||||
}
|
||||
|
||||
else -> LightGray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,506 @@
|
|||
package club.clubk.ktag.apps.bletool
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Intent
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import club.clubk.ktag.apps.bletool.ui.theme.KTagBLEToolTheme
|
||||
|
||||
private const val TAG = "BLE Scanner"
|
||||
|
||||
class ScannerActivity : ComponentActivity() {
|
||||
private var isScanning by mutableStateOf(false)
|
||||
private var showLocationDialog by mutableStateOf(false)
|
||||
private val scannedDevices = mutableStateMapOf<String, KTagPacket>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
KTagBLEToolTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
ScannerScreen(
|
||||
isScanning = isScanning,
|
||||
devices = scannedDevices,
|
||||
onStartScan = { startScanning() },
|
||||
onStopScan = { stopScanning() }
|
||||
)
|
||||
|
||||
if (showLocationDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLocationDialog = false },
|
||||
title = { Text("Location Services Required") },
|
||||
text = { Text("BLE scanning requires Location Services to be enabled. Please enable Location in your device settings.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showLocationDialog = false
|
||||
startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||
}) {
|
||||
Text("Open Settings")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLocationDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLocationEnabled(): Boolean {
|
||||
val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
|
||||
return locationManager.isLocationEnabled
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startScanning() {
|
||||
if (!isLocationEnabled()) {
|
||||
Log.w(TAG, "Location services are disabled, cannot scan")
|
||||
showLocationDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
scannedDevices.clear()
|
||||
|
||||
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val scanner = bluetoothManager.adapter.bluetoothLeScanner ?: run {
|
||||
Log.e(TAG, "BLE scanning not supported")
|
||||
return
|
||||
}
|
||||
|
||||
val filter = ScanFilter.Builder()
|
||||
.setManufacturerData(
|
||||
0xFFFF,
|
||||
byteArrayOf(0x4B, 0x54, 0x61, 0x67) // "KTag"
|
||||
)
|
||||
.build()
|
||||
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
|
||||
scanner.startScan(listOf(filter), settings, scanCallback)
|
||||
isScanning = true
|
||||
}
|
||||
|
||||
private fun stopScanning() {
|
||||
val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val scanner = bluetoothManager.adapter.bluetoothLeScanner
|
||||
scanner?.stopScan(scanCallback)
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
|
||||
if (data.size >= 4 &&
|
||||
data[0] == 0x4B.toByte() && // K
|
||||
data[1] == 0x54.toByte() && // T
|
||||
data[2] == 0x61.toByte() && // a
|
||||
data[3] == 0x67.toByte() // g
|
||||
) {
|
||||
val packet = parseKTagPacket(data)
|
||||
scannedDevices[result.device.address] = packet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
val errorMessage = when (errorCode) {
|
||||
SCAN_FAILED_ALREADY_STARTED -> "Already started"
|
||||
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
|
||||
SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
|
||||
SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
|
||||
else -> "Unknown error $errorCode"
|
||||
}
|
||||
Log.e(TAG, "BLE Scan failed: $errorMessage")
|
||||
isScanning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScannerScreen(
|
||||
isScanning: Boolean,
|
||||
devices: Map<String, KTagPacket>,
|
||||
onStartScan: () -> Unit,
|
||||
onStopScan: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
TitleBox()
|
||||
|
||||
Button(
|
||||
onClick = if (isScanning) onStopScan else onStartScan,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Text(if (isScanning) "Stop Scanning" else "Start Scanning")
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(devices.entries.toList()) { (address, packet) ->
|
||||
PacketCard(address = address, packet = packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PacketCard(address: String, packet: KTagPacket) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = packet.typeName,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
when (packet) {
|
||||
is InstigateGamePacket -> {
|
||||
PacketRow("Game Length", "${packet.gameLength}ms")
|
||||
PacketRow("Countdown", "${packet.countdownTime}ms")
|
||||
}
|
||||
|
||||
is EventPacket -> {
|
||||
PacketRow("Target", packet.targetAddress)
|
||||
PacketRow("Event ID", packet.eventId.toString())
|
||||
PacketRow("Event Data", packet.eventData.toString())
|
||||
}
|
||||
|
||||
is TagPacket -> {
|
||||
PacketRow("Team/Player", "${packet.teamId}/${packet.playerId}")
|
||||
PacketRow("Damage", packet.damage.toString())
|
||||
PacketRow("Protocol", packet.protocol.toString())
|
||||
PacketRow("TX Power", "${packet.txPower}dBm")
|
||||
PacketRow("Target", packet.targetAddress)
|
||||
PacketRow("Color", String.format("#%08X", packet.color))
|
||||
}
|
||||
|
||||
is ConsolePacket -> {
|
||||
PacketRow("Message", packet.consoleString)
|
||||
}
|
||||
|
||||
is StatusPacket -> {
|
||||
PacketRow("Team/Player", "${packet.teamId}/${packet.playerId}")
|
||||
PacketRow("Health", "${packet.health}/${packet.maxHealth}")
|
||||
PacketRow("Protocol", packet.protocol.toString())
|
||||
PacketRow("TX Power", "${packet.txPower}dBm")
|
||||
PacketRow("Primary Color", String.format("#%08X", packet.primaryColor))
|
||||
PacketRow("Secondary Color", String.format("#%08X", packet.secondaryColor))
|
||||
PacketRow("State", packet.SystemKState.toString())
|
||||
}
|
||||
|
||||
is ParametersPacket -> {
|
||||
PacketRow("Target", packet.targetAddress)
|
||||
PacketRow("Subtype", packet.subtype.toString())
|
||||
PacketRow("Key 1", packet.key1.toString())
|
||||
PacketRow("Value 1", packet.value1.toString())
|
||||
PacketRow("Key 2", packet.key2.toString())
|
||||
PacketRow("Value 2", packet.value2.toString())
|
||||
}
|
||||
|
||||
is HelloPacket -> {
|
||||
PacketRow("Version", String.format("SystemK v%d.%02d", packet.majorVersion, packet.minorVersion))
|
||||
PacketRow("Device Type", Packet.getDeviceTypeName(packet.deviceType))
|
||||
PacketRow("Team ID", packet.teamId.toString())
|
||||
PacketRow("Device Name", packet.deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Event #${packet.eventNumber}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PacketRow(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Data classes for packet parsing
|
||||
sealed class KTagPacket {
|
||||
abstract val type: Int
|
||||
abstract val typeName: String
|
||||
abstract val eventNumber: Int
|
||||
}
|
||||
|
||||
data class InstigateGamePacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Instigate Game",
|
||||
override val eventNumber: Int,
|
||||
val gameLength: Int,
|
||||
val countdownTime: Int
|
||||
) : KTagPacket()
|
||||
|
||||
data class EventPacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Event",
|
||||
override val eventNumber: Int,
|
||||
val targetAddress: String,
|
||||
val eventId: Int,
|
||||
val eventData: Int
|
||||
) : KTagPacket()
|
||||
|
||||
data class TagPacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Tag",
|
||||
override val eventNumber: Int,
|
||||
val txPower: Int,
|
||||
val protocol: Int,
|
||||
val teamId: Int,
|
||||
val playerId: Int,
|
||||
val damage: Int,
|
||||
val color: Int,
|
||||
val targetAddress: String
|
||||
) : KTagPacket()
|
||||
|
||||
data class ConsolePacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Console",
|
||||
override val eventNumber: Int,
|
||||
val consoleString: String
|
||||
) : KTagPacket()
|
||||
|
||||
data class StatusPacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Status",
|
||||
override val eventNumber: Int,
|
||||
val txPower: Int,
|
||||
val protocol: Int,
|
||||
val teamId: Int,
|
||||
val playerId: Int,
|
||||
val health: Int,
|
||||
val maxHealth: Int,
|
||||
val primaryColor: Int,
|
||||
val secondaryColor: Int,
|
||||
val SystemKState: Int
|
||||
) : KTagPacket()
|
||||
|
||||
data class ParametersPacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Parameters",
|
||||
override val eventNumber: Int,
|
||||
val targetAddress: String,
|
||||
val subtype: Int,
|
||||
val key1: Int,
|
||||
val value1: Int,
|
||||
val key2: Int,
|
||||
val value2: Int
|
||||
) : KTagPacket()
|
||||
|
||||
data class HelloPacket(
|
||||
override val type: Int,
|
||||
override val typeName: String = "Hello",
|
||||
override val eventNumber: Int,
|
||||
val majorVersion: Int,
|
||||
val minorVersion: Int,
|
||||
val deviceType: Int,
|
||||
val teamId: Int,
|
||||
val deviceName: String
|
||||
) : KTagPacket()
|
||||
|
||||
fun parseKTagPacket(data: ByteArray): KTagPacket {
|
||||
val type = data[4].toInt()
|
||||
val eventNumber = data[5].toInt()
|
||||
|
||||
return when (type) {
|
||||
0x01 -> InstigateGamePacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
gameLength = bytesToInt(data, 6, 4),
|
||||
countdownTime = bytesToInt(data, 10, 4)
|
||||
)
|
||||
|
||||
0x02 -> EventPacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
targetAddress = bytesToMacAddress(data, 6),
|
||||
eventId = bytesToInt(data, 12, 4),
|
||||
eventData = bytesToInt(data, 16, 4)
|
||||
)
|
||||
|
||||
0x03 -> TagPacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
txPower = data[6].toInt(),
|
||||
protocol = data[7].toInt(),
|
||||
teamId = data[8].toInt(),
|
||||
playerId = data[9].toInt(),
|
||||
damage = bytesToInt(data, 10, 2),
|
||||
color = bytesToInt(data, 12, 4),
|
||||
targetAddress = bytesToMacAddress(data, 16)
|
||||
)
|
||||
|
||||
0x04 -> ConsolePacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
consoleString = data.slice(6..26).toByteArray().let { bytes ->
|
||||
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
|
||||
if (nullPos >= 0) {
|
||||
bytes.slice(0 until nullPos).toByteArray()
|
||||
} else {
|
||||
bytes
|
||||
}.toString(Charsets.US_ASCII).trim()
|
||||
}
|
||||
)
|
||||
|
||||
0x05 -> StatusPacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
txPower = data[6].toInt(),
|
||||
protocol = data[7].toInt(),
|
||||
teamId = data[8].toInt(),
|
||||
playerId = data[8].toInt(),
|
||||
health = bytesToInt(data, 10, 2),
|
||||
maxHealth = bytesToInt(data, 12, 2),
|
||||
primaryColor = bytesToInt(data, 14, 4),
|
||||
secondaryColor = bytesToInt(data, 18, 4),
|
||||
SystemKState = data[22].toInt()
|
||||
)
|
||||
|
||||
0x06 -> ParametersPacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
targetAddress = bytesToMacAddress(data, 6),
|
||||
subtype = data[12].toInt(),
|
||||
key1 = bytesToInt(data, 13, 2),
|
||||
value1 = bytesToInt(data, 15, 4),
|
||||
key2 = bytesToInt(data, 19, 2),
|
||||
value2 = bytesToInt(data, 21, 4)
|
||||
)
|
||||
|
||||
0x07 -> HelloPacket(
|
||||
type = type,
|
||||
eventNumber = eventNumber,
|
||||
majorVersion = data[6].toInt(),
|
||||
minorVersion = data[7].toInt(),
|
||||
deviceType = bytesToInt(data, 8, 2),
|
||||
teamId = data[10].toInt(),
|
||||
deviceName = data.slice(11..26).toByteArray().let { bytes ->
|
||||
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
|
||||
if (nullPos >= 0) {
|
||||
bytes.slice(0 until nullPos).toByteArray()
|
||||
} else {
|
||||
bytes
|
||||
}.toString(Charsets.US_ASCII).trim()
|
||||
}
|
||||
)
|
||||
|
||||
else -> StatusPacket(
|
||||
type = type,
|
||||
typeName = "Unknown",
|
||||
eventNumber = eventNumber,
|
||||
txPower = 0,
|
||||
protocol = 0,
|
||||
teamId = 0,
|
||||
playerId = 0,
|
||||
health = 0,
|
||||
maxHealth = 0,
|
||||
primaryColor = 0,
|
||||
secondaryColor = 0,
|
||||
SystemKState = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytesToInt(data: ByteArray, offset: Int, length: Int): Int {
|
||||
var result = 0
|
||||
for (i in 0 until length) {
|
||||
result = result or ((data[offset + i].toInt() and 0xFF) shl (8 * i))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun bytesToMacAddress(data: ByteArray, offset: Int): String {
|
||||
return (0..5).joinToString(":") {
|
||||
String.format("%02X", data[offset + it])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package club.clubk.ktag.apps.bletool.ui.theme
|
||||
|
||||
// Re-export core colors for use in this module
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
// BLE Tool color aliases for clarity in module-specific code
|
||||
val BleToolGreen = KTagGreen
|
||||
val BleToolBlue = KTagBlue
|
||||
val BleToolRed = KTagRed
|
||||
val BleToolYellow = KTagYellow
|
||||
val BleToolPurple = KTagPurple
|
||||
val BleToolDarkGray = KTagDarkGray
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package club.clubk.ktag.apps.bletool.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KTagBLEToolTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package club.clubk.ktag.apps.bletool.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Cursive,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
BIN
subapp-bletool/src/main/res/drawable/ic_bletool.webp
Normal file
|
After Width: | Height: | Size: 163 KiB |
264
subapp-konfigurator/README.md
Normal 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)
|
||||
36
subapp-konfigurator/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
28
subapp-konfigurator/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||