diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee280cd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE index 9f5a5dd..c86697c 100644 --- a/LICENSE +++ b/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. diff --git a/README.md b/README.md index 322f46f..1fc96b9 100644 --- a/README.md +++ b/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 { + override fun create(context: Context) { + SubAppRegistry.register(YourSubApp()) + } + + override fun dependencies(): List>> = emptyList() +} +``` + +### 6. Declare the Activity and Initializer in `AndroidManifest.xml` + +```xml + + + + + + + + + + +``` + +### 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 + + + + + + + + +``` + +### 5. Declare the settings activity in `AndroidManifest.xml` + +```xml + + + +``` + +### 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. + +🛡 🃞 + +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 . + +## 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. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..209f0be --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ce155b2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..b62953b Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/club/clubk/ktag/apps/BleSettingsActivity.kt b/app/src/main/java/club/clubk/ktag/apps/BleSettingsActivity.kt new file mode 100644 index 0000000..4a6f175 --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/apps/BleSettingsActivity.kt @@ -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) + } + } +} diff --git a/app/src/main/java/club/clubk/ktag/apps/MainActivity.kt b/app/src/main/java/club/clubk/ktag/apps/MainActivity.kt new file mode 100644 index 0000000..9335f44 --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/apps/MainActivity.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/club/clubk/ktag/apps/ui/theme/Color.kt b/app/src/main/java/club/clubk/ktag/apps/ui/theme/Color.kt new file mode 100644 index 0000000..8cd7cb9 --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/apps/ui/theme/Color.kt @@ -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 diff --git a/app/src/main/java/club/clubk/ktag/apps/ui/theme/Theme.kt b/app/src/main/java/club/clubk/ktag/apps/ui/theme/Theme.kt new file mode 100644 index 0000000..5f8c94e --- /dev/null +++ b/app/src/main/java/club/clubk/ktag/apps/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..70d10cc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..f970ede Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..751eba9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..a91b77f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..94e70a8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..2d3b60e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1c96806 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..0b5bebb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5c762ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..af2f523 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3c285c7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d34fa65 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + KTag Apps + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..cbcf3ac --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + + + diff --git a/subapp-medic/src/main/res/xml/medic_settings_pref.xml b/subapp-medic/src/main/res/xml/medic_settings_pref.xml new file mode 100644 index 0000000..d402b52 --- /dev/null +++ b/subapp-medic/src/main/res/xml/medic_settings_pref.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/subapp-mine/README.md b/subapp-mine/README.md new file mode 100644 index 0000000..64c8959 --- /dev/null +++ b/subapp-mine/README.md @@ -0,0 +1,49 @@ +# KTag Mine Subapp + +A Jetpack Compose Android application that acts as a proximity-triggered "mine" for KTag laser tag devices. When an enemy device comes within range, the mine automatically tags it with configurable damage. + +## How It Works + +The Mine scans for nearby KTag devices broadcasting Status packets via BLE. When a device matching the target team enters the configured RSSI threshold, the mine: + +1. Triggers a "BOOM!" explosion animation with haptic feedback +2. Broadcasts a Tag packet to damage the target device +3. Enters a rearm countdown period +4. Returns to armed state, ready to tag again + +## State Machine + +The mine operates using a three-state machine: + +| State | Description | Display | +|-------|-------------|---------| +| **Armed** | Ready to tag incoming devices | "ARMED" watermark | +| **Tagging** | Explosion animation playing | BOOM! animation | +| **Rearming** | Countdown before re-arming | Countdown number | + +## Settings + +Access settings via the gear icon in the app bar. + +| Setting | Description | Default | +|---------|-------------|---------| +| **Minimum RSSI** | Signal strength threshold for triggering (-120 = far, 0 = close) | -60 dBm | +| **Damage Applied** | Amount of damage dealt to tagged devices | 100 | +| **Target Team** | Which team(s) to target (Red, Blue, or All) | All | +| **Time to Rearm** | Seconds before mine can tag again after detonation | 5 seconds | + +## UI Elements + +- **Status Bar**: Shows current target team, damage, and RSSI threshold +- **Device List**: Displays detected KTag devices with: + - Device name and BLE address + - Current RSSI signal strength + - Status: IN RANGE, OUT OF RANGE, TAGGED, or TAGGED OUT +- **Watermark**: Large centered text showing current state (ARMED or countdown) +- **Explosion Animation**: Full-screen animation with expanding rings and "BOOM!" text + +## Permissions + +- `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT` - BLE operations +- `ACCESS_COARSE_LOCATION` - Required for BLE scanning on older Android versions +- `VIBRATE` - Haptic feedback on explosion diff --git a/subapp-mine/build.gradle.kts b/subapp-mine/build.gradle.kts new file mode 100644 index 0000000..df6027b --- /dev/null +++ b/subapp-mine/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "club.clubk.ktag.apps.mine" + 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(libs.androidx.core.ktx) + implementation(libs.androidx.preference) + implementation(libs.androidx.startup) + + 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.lifecycle.viewmodel.compose) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} diff --git a/subapp-mine/src/main/AndroidManifest.xml b/subapp-mine/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2a6eec8 --- /dev/null +++ b/subapp-mine/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/DeviceModel.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/DeviceModel.kt new file mode 100644 index 0000000..d7f6a27 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/DeviceModel.kt @@ -0,0 +1,15 @@ +package club.clubk.ktag.apps.mine + +/** + * Team targeting settings. + */ +enum class TargetTeamSetting(val value: Int) { + RED(1), + BLUE(2), + ALL(3); + + companion object { + fun fromInt(value: Int): TargetTeamSetting = + entries.find { it.value == value } ?: ALL + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineActivity.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineActivity.kt new file mode 100644 index 0000000..a285e28 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineActivity.kt @@ -0,0 +1,265 @@ +package club.clubk.ktag.apps.mine + +import android.Manifest +import android.content.Context +import android.content.pm.ActivityInfo +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.activity.viewModels +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +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.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import club.clubk.ktag.apps.mine.ui.MineScreen +import club.clubk.ktag.apps.mine.ui.theme.MineTheme + +private const val TAG = "KTag Mine" + +class MineActivity : ComponentActivity() { + + private val viewModel: MineViewModel by viewModels() + + 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") + showMainContent() + } else { + val deniedPermissions = permissions.filter { !it.value }.keys + Log.w(TAG, "Some permissions denied: $deniedPermissions") + showPermissionDeniedContent(deniedPermissions.toList()) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate called") + + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + checkAndRequestPermissions() + } + + private fun checkAndRequestPermissions() { + Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}") + if (hasRequiredPermissions()) { + Log.i(TAG, "All required permissions already granted") + showMainContent() + } 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) + } + + private fun showMainContent() { + setContent { + MineTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MineMainScreen( + viewModel = viewModel, + context = this, + onSettingsClick = { openSettings() } + ) + } + } + } + } + + private fun showPermissionDeniedContent(deniedPermissions: List) { + setContent { + MineTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + PermissionDeniedScreen( + deniedPermissions = deniedPermissions, + onRequestPermissions = { requestPermissions() } + ) + } + } + } + } + + private fun openSettings() { + val intent = MineSettingsActivity.createIntent(this) + startActivity(intent) + } + + override fun onResume() { + super.onResume() + if (hasRequiredPermissions()) { + viewModel.loadPreferences(this) + } + } + + override fun onStart() { + super.onStart() + if (hasRequiredPermissions()) { + viewModel.startScanning() + } + } + + override fun onStop() { + super.onStop() + viewModel.stopScanning() + } + + override fun onDestroy() { + super.onDestroy() + viewModel.cleanup() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MineMainScreen( + viewModel: MineViewModel, + context: Context, + onSettingsClick: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + // Initialize ViewModel and handle BLE scanning lifecycle + DisposableEffect(Unit) { + viewModel.initialize(context) + viewModel.startScanning() + onDispose { + viewModel.stopScanning() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + actions = { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "Menu") + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_settings)) }, + onClick = { + showMenu = false + onSettingsClick() + } + ) + } + } + ) + } + ) { paddingValues -> + MineScreen( + viewModel = viewModel, + modifier = Modifier.padding(paddingValues) + ) + } +} + +@Composable +fun PermissionDeniedScreen( + deniedPermissions: List, + 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 = "KTag Mine needs the following permissions:", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp) + ) + deniedPermissions.forEach { permission -> + Text( + text = "\u2022 ${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") + } + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineInitializer.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineInitializer.kt new file mode 100644 index 0000000..9945e78 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineInitializer.kt @@ -0,0 +1,13 @@ +package club.clubk.ktag.apps.mine + +import android.content.Context +import androidx.startup.Initializer +import club.clubk.ktag.apps.core.SubAppRegistry + +class MineInitializer : Initializer { + override fun create(context: Context) { + SubAppRegistry.register(MineSubApp()) + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineSettingsActivity.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineSettingsActivity.kt new file mode 100644 index 0000000..d7e8402 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineSettingsActivity.kt @@ -0,0 +1,14 @@ +package club.clubk.ktag.apps.mine + +import android.content.Context +import android.content.Intent +import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity + +class MineSettingsActivity : BaseSettingsActivity() { + companion object { + @JvmStatic + fun createIntent(context: Context): Intent { + return BaseSettingsActivity.createIntent(context, R.xml.mine_settings_pref, MineSettingsActivity::class.java) + } + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineSubApp.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineSubApp.kt new file mode 100644 index 0000000..739447e --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineSubApp.kt @@ -0,0 +1,17 @@ +package club.clubk.ktag.apps.mine + +import android.content.Context +import android.content.Intent +import club.clubk.ktag.apps.sharedservices.SettingsSubApp + +class MineSubApp : SettingsSubApp { + override val id = "mine" + override val name = "Mine" + override val icon = R.drawable.ic_mine + override val settingsPreferencesResId = R.xml.mine_settings_pref + override val usesMqtt = false + + override fun createIntent(context: Context): Intent { + return Intent(context, MineActivity::class.java) + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineViewModel.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineViewModel.kt new file mode 100644 index 0000000..7caf87e --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/MineViewModel.kt @@ -0,0 +1,368 @@ +package club.clubk.ktag.apps.mine + +import android.annotation.SuppressLint +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.ScanResult +import android.content.Context +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import club.clubk.ktag.apps.core.DeviceModel +import club.clubk.ktag.apps.core.HexUtils +import club.clubk.ktag.apps.core.R +import club.clubk.ktag.apps.core.ble.Packet +import club.clubk.ktag.apps.core.ui.ColorUtils +import club.clubk.ktag.apps.core.ui.theme.KTagBlue +import club.clubk.ktag.apps.core.ui.theme.KTagPurple +import club.clubk.ktag.apps.core.ui.theme.KTagRed +import club.clubk.ktag.apps.sharedservices.BleViewModel +import club.clubk.ktag.apps.sharedservices.DeviceInfo +import club.clubk.ktag.apps.sharedservices.DevicePreferenceKeys +import club.clubk.ktag.apps.sharedservices.MinePreferenceKeys +import club.clubk.ktag.apps.sharedservices.getIntPref +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * State machine for the Mine's arming cycle. + */ +sealed class MineState { + /** Mine is armed and ready to tag devices */ + data object Armed : MineState() + + /** Mine is currently showing explosion animation */ + data object Tagging : MineState() + + /** Mine is rearming with countdown */ + data class Rearming(val countdownSeconds: Int) : MineState() +} + +class MineViewModel : BleViewModel() { + + override val logTag = "KTag Mine" + + companion object { + private const val CLEANUP_INTERVAL_MS = 500L + private const val RSSI_HYSTERESIS = 20 + private const val TAG_DURATION_MS = 3000L + private const val DEFAULT_DAMAGE = 100 + private var tagEventNumber: Byte = 0 + } + + // Device list + private val _devices = mutableStateListOf() + val devices: List get() = _devices + + // Mine state machine + var mineState by mutableStateOf(MineState.Armed) + private set + + var rearmTimeSeconds by mutableStateOf(5) + private set + + private var rearmJob: Job? = null + + fun onExplosionAnimationComplete() { + transitionToRearming() + } + + private fun transitionToRearming() { + rearmJob?.cancel() + mineState = MineState.Rearming(rearmTimeSeconds) + + rearmJob = viewModelScope.launch { + var countdown = rearmTimeSeconds + while (countdown > 0) { + delay(1000L) + countdown-- + mineState = MineState.Rearming(countdown) + } + resetTaggedDevices() + mineState = MineState.Armed + } + } + + private fun resetTaggedDevices() { + val iterator = _devices.listIterator() + while (iterator.hasNext()) { + val device = iterator.next() + if (device.hasBeenTagged) { + iterator.set(device.withReset()) + } + } + } + + // Settings + var targetTeamSetting by mutableStateOf(TargetTeamSetting.ALL) + private set + + var minimumRssi by mutableStateOf(-60) + private set + + var damage by mutableStateOf(DEFAULT_DAMAGE) + private set + + private var deviceTtlMs = DeviceModel.DEFAULT_TTL_MS + + val backgroundColorForTargetTeam: Color + get() = when (targetTeamSetting) { + TargetTeamSetting.RED -> ColorUtils.makePastel(KTagRed) + TargetTeamSetting.BLUE -> ColorUtils.makePastel(KTagBlue) + TargetTeamSetting.ALL -> ColorUtils.makePastel(KTagPurple) + } + + // Cleanup job + private var cleanupJob: Job? = null + + override fun onStatusPacket(result: ScanResult) { + val status = Packet.Status(result) + addOrRefreshDevice( + bleAddress = result.device.address, + rssi = result.rssi, + color = Color(status.primary_color), + health = status.health, + teamId = status.team_ID + ) + } + + override fun updateDeviceInfoFromRepository(infoMap: Map) { + applyDeviceInfoUpdate(_devices, infoMap) + } + + fun initialize(context: Context) { + initBluetooth(context) + loadPreferences(context) + initDeviceInfo(context) + } + + fun loadPreferences(context: Context) { + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context) + minimumRssi = sharedPrefs.getIntPref(MinePreferenceKeys.MIN_RSSI, MinePreferenceKeys.DEFAULT_MIN_RSSI) + targetTeamSetting = TargetTeamSetting.fromInt( + sharedPrefs.getIntPref(MinePreferenceKeys.TARGET_TEAM, MinePreferenceKeys.DEFAULT_TARGET_TEAM) + ) + damage = sharedPrefs.getIntPref(MinePreferenceKeys.DAMAGE, MinePreferenceKeys.DEFAULT_DAMAGE) + rearmTimeSeconds = sharedPrefs.getIntPref(MinePreferenceKeys.REARM_TIME, MinePreferenceKeys.DEFAULT_REARM_TIME) + deviceTtlMs = sharedPrefs.getIntPref(DevicePreferenceKeys.DEVICE_TTL, DevicePreferenceKeys.DEFAULT_DEVICE_TTL) * 1000 + Log.d(logTag, "minimumRSSI: $minimumRssi, targetTeamSetting: $targetTeamSetting, damage: $damage, rearmTime: $rearmTimeSeconds") + } + + @SuppressLint("MissingPermission") + fun startScanning() { + startBleScanning() + + cleanupJob?.cancel() + cleanupJob = viewModelScope.launch { + while (true) { + delay(CLEANUP_INTERVAL_MS) + cleanupExpiredDevices() + } + } + } + + @SuppressLint("MissingPermission") + fun stopScanning() { + stopBleScanning() + cleanupJob?.cancel() + cleanupJob = null + stopAdvertising() + } + + private fun cleanupExpiredDevices() { + val iterator = _devices.listIterator() + while (iterator.hasNext()) { + val device = iterator.next() + val updated = device.withDecrementedTtl(CLEANUP_INTERVAL_MS.toInt()) + if (updated.isExpired) { + iterator.remove() + } else { + iterator.set(updated) + } + } + } + + private fun addOrRefreshDevice( + bleAddress: String, + rssi: Int, + color: Color, + health: Int, + teamId: Byte + ) { + Log.d(logTag, "RSSI $rssi (min $minimumRssi), health: $health, teamId: $teamId") + + val matchesTeam = when (targetTeamSetting) { + TargetTeamSetting.ALL -> true + TargetTeamSetting.RED -> teamId == 1.toByte() + TargetTeamSetting.BLUE -> teamId == 2.toByte() + } + + val existingIndex = _devices.indexOfFirst { it.bleAddress == bleAddress } + + if (!matchesTeam) { + if (existingIndex != -1) { + Log.d(logTag, "Removing device (wrong team): $bleAddress") + _devices.removeAt(existingIndex) + } + return + } + + if (health <= 0) { + if (existingIndex != -1) { + val existingDevice = _devices[existingIndex] + if (!existingDevice.isTaggedOut) { + Log.d(logTag, "Device tagged out, showing status: $bleAddress") + _devices[existingIndex] = existingDevice.withTaggedOut(deviceTtlMs) + } + } + return + } + + val isInRange = rssi >= minimumRssi + + if (existingIndex == -1) { + val deviceName = deviceInfoRepository?.getName(bleAddress) ?: "KTag Device" + val info = deviceInfoRepository?.getInfo(bleAddress) + val newDevice = DeviceModel( + name = deviceName, + version = if (info != null) String.format("SystemK v%d.%02d", info.majorVersion, info.minorVersion) else "", + deviceType = info?.deviceTypeName ?: "", + id = 1, + image = R.drawable.ktag_shield_gray, + bleAddress = bleAddress, + rssi = rssi, + color = color, + health = health, + inRange = rssi >= minimumRssi + ) + _devices.add(newDevice) + Log.d(logTag, "Adding device: $bleAddress, inRange: $isInRange") + + if (isInRange) { + tagDevice(bleAddress) + _devices[_devices.size - 1] = _devices.last().withTagged() + } + } else { + val existingDevice = _devices[existingIndex] + + if (existingDevice.isTaggedOut) { + return + } + + val wasInRange = existingDevice.inRange + val updatedDevice = existingDevice + .withResetTtl(deviceTtlMs) + .withRssi(rssi, minimumRssi) + + _devices[existingIndex] = updatedDevice + + if (isInRange && !wasInRange && !existingDevice.hasBeenTagged) { + tagDevice(bleAddress) + _devices[existingIndex] = _devices[existingIndex].withTagged() + Log.d(logTag, "Device came into range, tagging: $bleAddress") + } + } + } + + @SuppressLint("MissingPermission") + fun tagDevice(bleAddress: String) { + if (mineState != MineState.Armed) { + Log.d(logTag, "Mine not ready - state: $mineState") + return + } + + Log.i(logTag, "Tagging $bleAddress with damage $damage") + + mineState = MineState.Tagging + + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(false) + .build() + + val tagBytes = createTagPacketWithDamage( + HexUtils.hexStringToByteArray(bleAddress, ':'), + damage + ) + val adData = AdvertiseData.Builder() + .addManufacturerData(0xFFFF, tagBytes) + .build() + + stopAdvertising() + + btAdvertiser?.startAdvertising(settings, adData, advertisingCallback) + + advertisingJob = viewModelScope.launch { + delay(TAG_DURATION_MS) + stopAdvertising() + } + } + + /** + * Creates a Tag packet with configurable damage. + * Positive damage deals damage, negative damage heals. + */ + private fun createTagPacketWithDamage(targetBdAddr: ByteArray, damageAmount: Int): ByteArray { + val reversedAddr = ByteArray(6) + for (i in 0 until 6) { + reversedAddr[i] = targetBdAddr[5 - i] + } + + val damageLsb = (damageAmount and 0xFF).toByte() + val damageMsb = ((damageAmount shr 8) and 0xFF).toByte() + + return byteArrayOf( + 'K'.code.toByte(), + 'T'.code.toByte(), + 'a'.code.toByte(), + 'g'.code.toByte(), + 0x03, // PACKET_TYPE_TAG + tagEventNumber++, // Event number + 4, // Tx Power Level (dBm) + 0x03, // Protocol + 0x00, // Team ID + 0xFF.toByte(), // Player ID + damageLsb, // Damage (lsb) + damageMsb, // Damage (msb) + 0xFF.toByte(), // Primary Color RED + 0xFF.toByte(), // Primary Color GREEN + 0xFF.toByte(), // Primary Color BLUE + 0xFE.toByte(), // Primary Color BRIGHTNESS + reversedAddr[0], + reversedAddr[1], + reversedAddr[2], + reversedAddr[3], + reversedAddr[4], + reversedAddr[5], + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte() + ) + } + + fun onDeviceClicked(device: DeviceModel) { + tagDevice(device.bleAddress) + } + + fun cleanup() { + stopScanning() + stopAdvertising() + rearmJob?.cancel() + rearmJob = null + cleanupDeviceInfo() + } + + override fun onCleared() { + super.onCleared() + cleanup() + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/DeviceCard.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/DeviceCard.kt new file mode 100644 index 0000000..607b9ca --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/DeviceCard.kt @@ -0,0 +1,149 @@ +package club.clubk.ktag.apps.mine.ui + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import club.clubk.ktag.apps.core.DeviceModel +import club.clubk.ktag.apps.mine.MineState +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.KTagRed +import club.clubk.ktag.apps.core.ui.theme.KTagYellow + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DeviceCard( + device: DeviceModel, + mineState: MineState, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + + // Only show tagged status when mine is armed + val showTaggedStatus = mineState is MineState.Armed && device.hasBeenTagged + + val cardColor = KTagDarkGray + val borderColor = when { + device.isTaggedOut -> KTagRed + showTaggedStatus -> KTagRed + device.inRange -> KTagGreen + else -> Color.Gray + } + val statusText = when { + device.isTaggedOut -> "TAGGED OUT!" + showTaggedStatus -> "TAGGED" + device.inRange -> "IN RANGE" + else -> "OUT OF RANGE" + } + val statusColor = when { + device.isTaggedOut -> KTagRed + showTaggedStatus -> KTagRed + device.inRange -> KTagGreen + else -> Color.Gray + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .border(2.dp, borderColor, RoundedCornerShape(10.dp)) + .combinedClickable( + onClick = onClick, + onLongClick = { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + onLongClick() + } + ), + shape = RoundedCornerShape(10.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 5.dp), + colors = CardDefaults.cardColors(containerColor = cardColor) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Device icon with color background + Image( + painter = painterResource(id = device.image), + contentDescription = "Device icon", + modifier = Modifier + .size(75.dp) + .clip(RoundedCornerShape(4.dp)) + .background(device.color) + ) + + // Device info + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = device.name, + style = MaterialTheme.typography.titleMedium, + color = KTagYellow, + textAlign = TextAlign.Center + ) + if (device.deviceType.isNotEmpty()) { + Text( + text = device.deviceType, + style = MaterialTheme.typography.bodySmall, + color = KTagYellow, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + } + Text( + text = device.bleAddress, + style = MaterialTheme.typography.bodySmall, + color = KTagYellow, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = "RSSI: ${device.rssi} dBm", + style = MaterialTheme.typography.bodySmall, + color = KTagYellow, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = statusText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = statusColor, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/MineExplosionAnimation.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/MineExplosionAnimation.kt new file mode 100644 index 0000000..9857044 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/MineExplosionAnimation.kt @@ -0,0 +1,177 @@ +package club.clubk.ktag.apps.mine.ui + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +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.sp +import club.clubk.ktag.apps.core.ui.theme.KTagRed +import club.clubk.ktag.apps.core.ui.theme.KTagYellow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun MineExplosionAnimation( + onAnimationComplete: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val flashAlpha = remember { Animatable(0f) } + val ring1Progress = remember { Animatable(0f) } + val ring2Progress = remember { Animatable(0f) } + val ring3Progress = remember { Animatable(0f) } + val textScale = remember { Animatable(0f) } + val textAlpha = remember { Animatable(0f) } + + LaunchedEffect(Unit) { + // Haptic feedback for explosion + triggerExplosionHaptic(context) + + // Initial flash + launch { + flashAlpha.animateTo(0.8f, tween(100)) + flashAlpha.animateTo(0f, tween(400)) + } + + // Expanding rings with staggered start + launch { + ring1Progress.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) + } + launch { + delay(100) + ring2Progress.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) + } + launch { + delay(200) + ring3Progress.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) + } + + // Text animation + launch { + delay(150) + textAlpha.snapTo(1f) + textScale.animateTo(1.2f, tween(200, easing = FastOutSlowInEasing)) + textScale.animateTo(1f, tween(100)) + } + + // Wait for animations to complete + delay(1200) + + // Fade out + textAlpha.animateTo(0f, tween(300)) + + onAnimationComplete() + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + // Flash overlay + if (flashAlpha.value > 0f) { + Box( + modifier = Modifier + .fillMaxSize() + .background(KTagYellow.copy(alpha = flashAlpha.value)) + ) + } + + // Explosion rings + Canvas(modifier = Modifier.fillMaxSize()) { + val center = Offset(size.width / 2, size.height / 2) + val maxRadius = size.maxDimension * 0.7f + + // Ring 1 - Yellow/Orange + if (ring1Progress.value > 0f) { + val radius = maxRadius * ring1Progress.value + val alpha = (1f - ring1Progress.value).coerceIn(0f, 1f) + val strokeWidth = 40f * (1f - ring1Progress.value * 0.5f) + drawCircle( + color = KTagYellow.copy(alpha = alpha), + radius = radius, + center = center, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } + + // Ring 2 - Red/Orange + if (ring2Progress.value > 0f) { + val radius = maxRadius * ring2Progress.value * 0.85f + val alpha = (1f - ring2Progress.value).coerceIn(0f, 1f) + val strokeWidth = 50f * (1f - ring2Progress.value * 0.5f) + drawCircle( + color = KTagRed.copy(alpha = alpha), + radius = radius, + center = center, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } + + // Ring 3 - Dark Red + if (ring3Progress.value > 0f) { + val radius = maxRadius * ring3Progress.value * 0.7f + val alpha = (1f - ring3Progress.value).coerceIn(0f, 1f) + val strokeWidth = 60f * (1f - ring3Progress.value * 0.5f) + drawCircle( + color = Color(0xFFAA0000).copy(alpha = alpha), + radius = radius, + center = center, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } + } + + // "BOOM!" text + if (textAlpha.value > 0f) { + Text( + text = "BOOM!", + fontSize = (72 * textScale.value).sp, + fontWeight = FontWeight.ExtraBold, + color = KTagRed.copy(alpha = textAlpha.value), + textAlign = TextAlign.Center + ) + } + } +} + +private fun triggerExplosionHaptic(context: Context) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Strong explosion vibration pattern: initial burst + aftershock + val timings = longArrayOf(0, 100, 50, 150) + val amplitudes = intArrayOf(0, 255, 0, 180) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(longArrayOf(0, 100, 50, 150), -1) + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/MineScreen.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/MineScreen.kt new file mode 100644 index 0000000..82df8f5 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/MineScreen.kt @@ -0,0 +1,178 @@ +package club.clubk.ktag.apps.mine.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import club.clubk.ktag.apps.core.ui.theme.KTagBlue +import club.clubk.ktag.apps.core.ui.theme.KTagPurple +import club.clubk.ktag.apps.core.ui.theme.KTagRed +import club.clubk.ktag.apps.core.DeviceModel +import club.clubk.ktag.apps.core.ui.RenameDeviceDialog +import club.clubk.ktag.apps.mine.MineState +import club.clubk.ktag.apps.mine.MineViewModel +import club.clubk.ktag.apps.mine.TargetTeamSetting + +@Composable +fun MineScreen( + viewModel: MineViewModel, + modifier: Modifier = Modifier +) { + val devices = viewModel.devices + val targetTeam = viewModel.targetTeamSetting + val damage = viewModel.damage + val minRssi = viewModel.minimumRssi + val mineState = viewModel.mineState + + // Rename dialog state + var deviceToRename by remember { mutableStateOf(null) } + + val teamColor = when (targetTeam) { + TargetTeamSetting.RED -> KTagRed + TargetTeamSetting.BLUE -> KTagBlue + TargetTeamSetting.ALL -> KTagPurple + } + + // Rename dialog + deviceToRename?.let { device -> + RenameDeviceDialog( + macAddress = device.bleAddress, + currentName = device.name, + onDismiss = { deviceToRename = null }, + onSave = { newName -> + viewModel.renameDevice(device.bleAddress, newName) + deviceToRename = null + } + ) + } + + Box(modifier = modifier.fillMaxSize()) { + // Main content + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Status bar showing current settings + MineStatusBar( + targetTeam = targetTeam, + damage = damage, + minRssi = minRssi + ) + + // Device list area with "ARMED" watermark + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(Color.Black) + ) { + // Watermark text based on mine state + when (mineState) { + is MineState.Armed -> { + Text( + text = "ARMED", + fontSize = 64.sp, + fontWeight = FontWeight.ExtraBold, + color = teamColor.copy(alpha = 0.3f), + modifier = Modifier.align(Alignment.Center) + ) + } + is MineState.Rearming -> { + Text( + text = mineState.countdownSeconds.toString(), + fontSize = 120.sp, + fontWeight = FontWeight.ExtraBold, + color = teamColor.copy(alpha = 0.3f), + modifier = Modifier.align(Alignment.Center) + ) + } + is MineState.Tagging -> { + // No watermark during explosion + } + } + + // Device list + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items( + items = devices, + key = { it.bleAddress } + ) { device -> + DeviceCard( + device = device, + mineState = mineState, + onClick = { viewModel.onDeviceClicked(device) }, + onLongClick = { deviceToRename = device } + ) + } + } + } + } + + // Explosion animation overlay + if (mineState is MineState.Tagging) { + MineExplosionAnimation( + onAnimationComplete = { viewModel.onExplosionAnimationComplete() } + ) + } + } +} + +@Composable +fun MineStatusBar( + targetTeam: TargetTeamSetting, + damage: Int, + minRssi: Int, + modifier: Modifier = Modifier +) { + val teamText = when (targetTeam) { + TargetTeamSetting.RED -> "Red Team" + TargetTeamSetting.BLUE -> "Blue Team" + TargetTeamSetting.ALL -> "All Teams" + } + + val teamColor = when (targetTeam) { + TargetTeamSetting.RED -> KTagRed + TargetTeamSetting.BLUE -> KTagBlue + TargetTeamSetting.ALL -> KTagPurple + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(Color(0xFF1A1A1A)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Targeting: $teamText", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = teamColor + ) + Text( + text = "Damage: $damage | Min RSSI: $minRssi dBm", + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray + ) + } +} diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/theme/Color.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/theme/Color.kt new file mode 100644 index 0000000..83a3845 --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/theme/Color.kt @@ -0,0 +1,13 @@ +package club.clubk.ktag.apps.mine.ui.theme + +import androidx.compose.ui.graphics.Color +import club.clubk.ktag.apps.core.ui.theme.KTagBlue +import club.clubk.ktag.apps.core.ui.theme.KTagPurple +import club.clubk.ktag.apps.core.ui.theme.KTagRed + +// Mine color aliases for clarity in module-specific code +val MineGrey = Color(0xFFE0E0E0) +val MineWhite = Color(0xFFFEFEFE) +val MineRed = KTagRed +val MineBlue = KTagBlue +val MinePurple = KTagPurple diff --git a/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/theme/Theme.kt b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/theme/Theme.kt new file mode 100644 index 0000000..f49405c --- /dev/null +++ b/subapp-mine/src/main/java/club/clubk/ktag/apps/mine/ui/theme/Theme.kt @@ -0,0 +1,73 @@ +package club.clubk.ktag.apps.mine.ui.theme + +import android.app.Activity +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.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.compose.ui.graphics.Color +import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray +import club.clubk.ktag.apps.core.ui.theme.KTagRed +import club.clubk.ktag.apps.core.ui.theme.KTagPurple +import club.clubk.ktag.apps.core.ui.theme.KTagYellow +import club.clubk.ktag.apps.core.ui.theme.Typography + +private val DarkColorScheme = darkColorScheme( + primary = KTagDarkGray, + onPrimary = Color.White, + secondary = KTagYellow, + onSecondary = KTagDarkGray, + tertiary = KTagPurple, + background = Color.Black, + surface = KTagDarkGray +) + +private val LightColorScheme = lightColorScheme( + primary = KTagDarkGray, + onPrimary = Color.White, + secondary = KTagYellow, + onSecondary = KTagDarkGray, + tertiary = KTagPurple, + background = Color.Black, + surface = KTagDarkGray +) + +@Composable +fun MineTheme( + 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 + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/subapp-mine/src/main/res/drawable/ic_mine.png b/subapp-mine/src/main/res/drawable/ic_mine.png new file mode 100644 index 0000000..f6ea730 Binary files /dev/null and b/subapp-mine/src/main/res/drawable/ic_mine.png differ diff --git a/subapp-mine/src/main/res/values/arrays.xml b/subapp-mine/src/main/res/values/arrays.xml new file mode 100644 index 0000000..920cdb8 --- /dev/null +++ b/subapp-mine/src/main/res/values/arrays.xml @@ -0,0 +1,13 @@ + + + + Red + Blue + All + + + 1 + 2 + 3 + + diff --git a/subapp-mine/src/main/res/values/strings.xml b/subapp-mine/src/main/res/values/strings.xml new file mode 100644 index 0000000..e9ddedc --- /dev/null +++ b/subapp-mine/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + KTag Mine + Hello world! + Settings + diff --git a/subapp-mine/src/main/res/values/styles.xml b/subapp-mine/src/main/res/values/styles.xml new file mode 100644 index 0000000..766ab99 --- /dev/null +++ b/subapp-mine/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/subapp-mine/src/main/res/xml/mine_settings_pref.xml b/subapp-mine/src/main/res/xml/mine_settings_pref.xml new file mode 100644 index 0000000..a1044bd --- /dev/null +++ b/subapp-mine/src/main/res/xml/mine_settings_pref.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + diff --git a/subapp-sample/README.md b/subapp-sample/README.md new file mode 100644 index 0000000..2ce9723 --- /dev/null +++ b/subapp-sample/README.md @@ -0,0 +1,104 @@ +# KTag Sample Subapp + +A minimal Jetpack Compose subapp template for creating new KTag applications. + +## Overview + +The Sample subapp provides a starting point for creating new subapps in the KTag ecosystem. It demonstrates the minimal required structure and can be copied as a template for new functionality. + +## Architecture + +The simplest possible subapp structure: + +``` +┌─────────────────────────────────────────────────────────┐ +│ SampleActivity │ +│ (Compose Host) │ +│ • Single "Hello World" screen │ +└─────────────────────────────────────────────────────────┘ +``` + +## File Structure + +``` +src/main/java/club/clubk/ktag/apps/sample/ +├── SampleActivity.kt # Main activity with Compose UI +├── SampleSubApp.kt # Subapp registration +└── SampleInitializer.kt # Startup initializer +``` + +## Creating a New Subapp + +To create a new subapp based on this template: + +1. **Copy the module**: Duplicate `subapp-sample` directory +2. **Rename the package**: Update package name in all files +3. **Update build.gradle.kts**: Change namespace +4. **Register the subapp**: Update `SampleSubApp` with new ID, name, icon +5. **Add to settings.gradle.kts**: Include new module +6. **Add dependency**: Include in main app's dependencies + +## Required Components + +### SubApp Interface + +Every subapp must implement the `SubApp` interface: + +```kotlin +class MySubApp : SubApp { + override val id = "myapp" // Unique identifier + override val name = "My App" // Display name + override val icon = R.drawable.ic_myapp // Launcher icon + + override fun createIntent(context: Context): Intent { + return Intent(context, MyActivity::class.java) + } +} +``` + +### Initializer + +Register the subapp at startup: + +```kotlin +class MyInitializer : Initializer { + override fun create(context: Context) { + SubAppRegistry.register(MySubApp()) + } + + override fun dependencies(): List>> = emptyList() +} +``` + +### AndroidManifest.xml + +Declare the activity and initializer: + +```xml + + + + + +``` + +## Optional Features + +For more complex subapps, consider adding: + +| Feature | Reference | +|---------|-----------| +| Settings | See `subapp-koth` or `subapp-medic` | +| MQTT | Implement `SettingsSubApp` interface | +| BLE | See `subapp-bletool` or `subapp-koth` | +| USB Serial | See `subapp-terminal` | +| ViewModel | See `subapp-koth` or `subapp-medic` | + +## Dependencies + +- Jetpack Compose (Material3) +- AndroidX Startup (for initialization) diff --git a/subapp-sample/build.gradle.kts b/subapp-sample/build.gradle.kts new file mode 100644 index 0000000..2503917 --- /dev/null +++ b/subapp-sample/build.gradle.kts @@ -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.sample" + 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().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} diff --git a/subapp-sample/src/main/AndroidManifest.xml b/subapp-sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9d3d802 --- /dev/null +++ b/subapp-sample/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleActivity.kt b/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleActivity.kt new file mode 100644 index 0000000..f8eb84d --- /dev/null +++ b/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleActivity.kt @@ -0,0 +1,30 @@ +package club.clubk.ktag.apps.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +class SampleActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + Text( + text = "Hello from Sample SubApp!", + style = MaterialTheme.typography.headlineMedium + ) + } + } + } + } + } +} diff --git a/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleInitializer.kt b/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleInitializer.kt new file mode 100644 index 0000000..31a1da9 --- /dev/null +++ b/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleInitializer.kt @@ -0,0 +1,13 @@ +package club.clubk.ktag.apps.sample + +import android.content.Context +import androidx.startup.Initializer +import club.clubk.ktag.apps.core.SubAppRegistry + +class SampleInitializer : Initializer { + override fun create(context: Context) { + SubAppRegistry.register(SampleSubApp()) + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleSubApp.kt b/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleSubApp.kt new file mode 100644 index 0000000..9c79a90 --- /dev/null +++ b/subapp-sample/src/main/java/club/clubk/ktag/apps/sample/SampleSubApp.kt @@ -0,0 +1,15 @@ +package club.clubk.ktag.apps.sample + +import android.content.Context +import android.content.Intent +import club.clubk.ktag.apps.core.SubApp + +class SampleSubApp : SubApp { + override val id = "sample" + override val name = "Sample" + override val icon = R.drawable.ic_sample + + override fun createIntent(context: Context): Intent { + return Intent(context, SampleActivity::class.java) + } +} diff --git a/subapp-sample/src/main/res/drawable/ic_sample.xml b/subapp-sample/src/main/res/drawable/ic_sample.xml new file mode 100644 index 0000000..37022ed --- /dev/null +++ b/subapp-sample/src/main/res/drawable/ic_sample.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/subapp-sentry/README.md b/subapp-sentry/README.md new file mode 100644 index 0000000..8026980 --- /dev/null +++ b/subapp-sentry/README.md @@ -0,0 +1,86 @@ +# Sentry + +An autonomous sentry gun subapp that uses the device camera and on-device machine learning to detect people and automatically send a tag from a KTag blaster connected via USB when a person is detected in the crosshair. + +## How It Works + +1. The back camera streams frames continuously through CameraX. +2. Each frame is analyzed by **MediaPipe Tasks Vision** running the **EfficientDet-Lite0** object detection model entirely on-device — no network connection required. +3. When a person is detected with ≥ 50 % confidence, a red bounding box is drawn over them and a continuous 880 Hz tone plays (if enabled). +4. If the bounding box overlaps the crosshair at the centre of the screen, the sentry fires: it sends a trigger-press command followed by a trigger-release command over USB serial to the connected KTag device. +5. A cooldown period prevents the sentry from firing again until the timer expires. + +## Setup + +### 1. Download the detection model + +The EfficientDet-Lite0 model is not bundled with the APK and must be placed manually: + +``` +subapp-sentry/src/main/assets/efficientdet_lite0.tflite +``` + +Download the **TFLite (efficientdet/lite0/detection/metadata/1)** file from: + +> https://tfhub.dev/tensorflow/lite-model/efficientdet/lite0/detection/metadata/1 + +### 2. Connect a KTag device via USB + +Plug a KTag device into the Android device using a USB OTG cable. The app will detect it automatically and show it in the device selector. + +### 3. Grant camera permission + +The app requests `CAMERA` permission on first launch. Deny it and the camera preview will not start. + +## UI + +| Element | Description | +|----------------------|-----------------------------------------------------------------------------------------------------| +| Camera preview | Full-screen live view from the back camera | +| Red bounding box | Drawn around any detected person | +| White crosshair | Fixed plus symbol at the centre of the screen; firing only occurs when it overlaps the bounding box | +| **DETECTED** badge | Appears in the top-right corner whenever a person is visible | +| Device selector | Drop-down listing connected USB serial devices | +| Connect / Disconnect | Connects to the selected USB device or drops the connection | +| Status bar | Shows connection state, configured team, and time since last trigger | + +## Settings + +Open the settings screen via the gear icon in the top-right corner. + +| Setting | Options | Default | Description | +|---------------------|----------------------|---------|----------------------------------------------------------| +| Tag Team | Blue, Red, Purple | Blue | Team argument sent in the trigger command | +| Trigger Cooldown | 3 s, 5 s, 10 s, 30 s | 5 s | Minimum time between consecutive trigger firings | +| Play Detection Tone | On / Off | On | Plays a continuous 880 Hz tone while a person is visible | + +## USB Serial Protocol + +When the sentry fires, it simulates a trigger press by sending two commands in sequence: + +``` +KEvent 7 0\n ← trigger press +KEvent 8 0\n ← trigger release +``` + +The device selector lists all recognised USB serial devices. The app requests USB permission automatically on first connect. + +## Audio + +| Sound | Condition | Details | +|------------------------|-------------------------|----------------------------------------------------------------------------------------------| +| Continuous 880 Hz tone | Person visible in frame | `AudioTrack` sine wave; plays until person leaves frame or "Play Detection Tone" is disabled | +| Short beep | Sentry fires | 200 ms `ToneGenerator` beep | + +Both sounds play on the media audio stream and respect the device's media volume. + +## Dependencies + +| Library | Version | Purpose | +|------------------------|---------|----------------------------------------------------------| +| CameraX | 1.4.0 | Camera preview and frame capture | +| MediaPipe Tasks Vision | 0.10.32 | On-device object detection | +| EfficientDet-Lite0 | 1 | Person detection model (Apache-2.0, download separately) | +| usb-serial-for-android | 3.10.0 | USB serial communication with KTag hardware | + +> **Note:** MediaPipe transitively includes Firebase. The `AndroidManifest.xml` explicitly removes `FirebaseInitProvider` via `tools:node="remove"` to prevent any Firebase initialisation. diff --git a/subapp-sentry/build.gradle.kts b/subapp-sentry/build.gradle.kts new file mode 100644 index 0000000..a04c013 --- /dev/null +++ b/subapp-sentry/build.gradle.kts @@ -0,0 +1,55 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "club.clubk.ktag.apps.sentry" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + androidResources { + noCompress += "tflite" + } +} + +dependencies { + implementation(project(":core")) + implementation(project(":shared-services")) + + implementation("com.github.mik3y:usb-serial-for-android:3.10.0") + implementation("androidx.camera:camera-core:1.4.0") + implementation("androidx.camera:camera-camera2:1.4.0") + implementation("androidx.camera:camera-lifecycle:1.4.0") + implementation("androidx.camera:camera-view:1.4.0") + implementation("com.google.mediapipe:tasks-vision:0.10.32") + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.preference) + implementation(libs.androidx.lifecycle.viewmodel.compose) + 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().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} diff --git a/subapp-sentry/src/main/AndroidManifest.xml b/subapp-sentry/src/main/AndroidManifest.xml new file mode 100644 index 0000000..aee8005 --- /dev/null +++ b/subapp-sentry/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/subapp-sentry/src/main/assets/MODEL.txt b/subapp-sentry/src/main/assets/MODEL.txt new file mode 100644 index 0000000..06c146e --- /dev/null +++ b/subapp-sentry/src/main/assets/MODEL.txt @@ -0,0 +1,8 @@ +Place the EfficientDet-Lite0 person detection model here: + + File: efficientdet_lite0.tflite + Source: https://tfhub.dev/tensorflow/lite-model/efficientdet/lite0/detection/metadata/1 + License: Apache-2.0 + +Download the "TFLite (efficientdet/lite0/detection/metadata/1)" file from the URL above +and save it to this directory as efficientdet_lite0.tflite. diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryActivity.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryActivity.kt new file mode 100644 index 0000000..9b9eb16 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryActivity.kt @@ -0,0 +1,413 @@ +package club.clubk.ktag.apps.sentry + +import android.Manifest +import android.content.pm.PackageManager +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.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.delay +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import club.clubk.ktag.apps.core.UsbSerialManager +import com.hoho.android.usbserial.driver.UsbSerialDriver +import java.util.concurrent.Executors + +private const val TAG = "SentryActivity" + +class SentryActivity : ComponentActivity() { + + private val requestCameraPermission = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (!granted) { + Log.w(TAG, "Camera permission denied") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) { + requestCameraPermission.launch(Manifest.permission.CAMERA) + } + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SentryScreen() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SentryScreen(vm: SentryViewModel = viewModel()) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val connectionState by vm.connectionState.collectAsState() + val availableDevices by vm.availableDevices.collectAsState() + val detectionResult by vm.detectionResult.collectAsState() + val lastTriggerMs by vm.lastTriggerMs.collectAsState() + val config by vm.config.collectAsState() + + var selectedDevice by remember { mutableStateOf(null) } + var showDeviceMenu by remember { mutableStateOf(false) } + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + + // Register/unregister USB + DisposableEffect(vm) { + vm.registerUsb() + onDispose { vm.unregisterUsb() } + } + + // Cleanup camera executor on disposal + DisposableEffect(cameraExecutor) { + onDispose { cameraExecutor.shutdown() } + } + + // Reload config on every resume so settings changes take effect immediately + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) vm.reloadConfig() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + // Auto-select first available device + LaunchedEffect(availableDevices) { + if (selectedDevice == null && availableDevices.isNotEmpty()) { + selectedDevice = availableDevices.first() + } + if (selectedDevice != null && availableDevices.none { + it.device.deviceId == selectedDevice?.device?.deviceId + } + ) { + selectedDevice = null + } + } + + val teamLabel = when (config.team) { + "asblue" -> "Blue" + "asred" -> "Red" + "aspurple" -> "Purple" + else -> config.team + } + + var currentTimeMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(500) + currentTimeMs = System.currentTimeMillis() + } + } + + val secondsSinceTrigger = if (lastTriggerMs > 0L) { + (currentTimeMs - lastTriggerMs) / 1000 + } else null + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Sentry") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF323031), + titleContentColor = Color(0xFFFFC857), + actionIconContentColor = Color(0xFFFFC857) + ), + actions = { + IconButton(onClick = { + context.startActivity(SentrySettingsActivity.createIntent(context)) + }) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Camera preview + bounding box overlay + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + // CameraX PreviewView - binds both Preview and ImageAnalysis + val hasCameraPermission = ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + + if (hasCameraPermission) { + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(cameraExecutor, vm.buildAnalyzer()) + } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis + ) + } catch (e: Exception) { + Log.e(TAG, "Camera binding failed", e) + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + }, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Text("Camera permission required", color = Color.White) + } + } + + // Bounding box + crosshair overlay + Canvas(modifier = Modifier.fillMaxSize()) { + detectionResult?.let { result -> + val left = result.left * size.width + val top = result.top * size.height + val right = result.right * size.width + val bottom = result.bottom * size.height + drawRect( + color = Color.Red, + topLeft = Offset(left, top), + size = Size(right - left, bottom - top), + style = Stroke(width = 4f) + ) + } + + // Crosshair + val cx = size.width / 2f + val cy = size.height / 2f + val arm = minOf(size.width, size.height) * 0.08f + val gap = minOf(size.width, size.height) * 0.02f + val crosshairColor = Color.White + val strokeWidth = 3f + drawLine(crosshairColor, Offset(cx - arm - gap, cy), Offset(cx - gap, cy), strokeWidth) + drawLine(crosshairColor, Offset(cx + gap, cy), Offset(cx + arm + gap, cy), strokeWidth) + drawLine(crosshairColor, Offset(cx, cy - arm - gap), Offset(cx, cy - gap), strokeWidth) + drawLine(crosshairColor, Offset(cx, cy + gap), Offset(cx, cy + arm + gap), strokeWidth) + } + + // Detected badge + if (detectionResult != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background(Color.Red, shape = MaterialTheme.shapes.small) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text("DETECTED", color = Color.White, fontSize = 12.sp) + } + } + } + + // Bottom control panel + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF323031)) + .padding(8.dp) + ) { + // Device selector + connect button + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { showDeviceMenu = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = selectedDevice?.device?.deviceName + ?: if (availableDevices.isEmpty()) "No devices" else "Select device", + maxLines = 1, + color = Color.White + ) + } + DropdownMenu( + expanded = showDeviceMenu, + onDismissRequest = { showDeviceMenu = false } + ) { + if (availableDevices.isEmpty()) { + DropdownMenuItem( + text = { Text("No USB serial devices found") }, + onClick = { showDeviceMenu = false } + ) + } else { + availableDevices.forEach { driver -> + DropdownMenuItem( + text = { + Text( + "${driver.device.deviceName} " + + "(${driver.javaClass.simpleName.removeSuffix("Driver")})" + ) + }, + onClick = { + selectedDevice = driver + showDeviceMenu = false + } + ) + } + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + when (connectionState) { + UsbSerialManager.ConnectionState.CONNECTED -> vm.disconnect() + UsbSerialManager.ConnectionState.DISCONNECTED, + UsbSerialManager.ConnectionState.ERROR -> { + selectedDevice?.let { vm.connect(it) } + } + else -> {} + } + }, + enabled = selectedDevice != null && + connectionState != UsbSerialManager.ConnectionState.CONNECTING && + connectionState != UsbSerialManager.ConnectionState.AWAITING_PERMISSION, + colors = ButtonDefaults.buttonColors( + containerColor = if (connectionState == UsbSerialManager.ConnectionState.CONNECTED) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.primary + ) + ) { + Text( + when (connectionState) { + UsbSerialManager.ConnectionState.CONNECTED -> "Disconnect" + UsbSerialManager.ConnectionState.CONNECTING -> "Connecting..." + UsbSerialManager.ConnectionState.AWAITING_PERMISSION -> "Requesting..." + else -> "Connect" + } + ) + } + } + + // Status row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val stateColor = when (connectionState) { + UsbSerialManager.ConnectionState.CONNECTED -> Color(0xFF4BA838) + UsbSerialManager.ConnectionState.ERROR -> Color.Red + else -> Color.Gray + } + Text( + text = connectionState.name, + color = stateColor, + fontSize = 12.sp + ) + Text( + text = " | Team: $teamLabel", + color = Color.White, + fontSize = 12.sp + ) + if (secondsSinceTrigger != null) { + Text( + text = " | Last trigger: ${secondsSinceTrigger}s ago", + color = Color(0xFFFFC857), + fontSize = 12.sp + ) + } + } + } + } + } +} diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryConfig.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryConfig.kt new file mode 100644 index 0000000..adbd424 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryConfig.kt @@ -0,0 +1,20 @@ +package club.clubk.ktag.apps.sentry + +import android.content.Context +import androidx.preference.PreferenceManager + +data class SentryConfig( + val team: String, + val cooldownMs: Long, + val playDetectionTone: Boolean +) { + companion object { + fun fromPreferences(context: Context): SentryConfig { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val team = prefs.getString(SentryPreferenceKeys.TEAM, "asblue") ?: "asblue" + val cooldownS = prefs.getString(SentryPreferenceKeys.COOLDOWN_S, "5")?.toLongOrNull() ?: 5L + val playDetectionTone = prefs.getBoolean(SentryPreferenceKeys.PLAY_DETECTION_TONE, true) + return SentryConfig(team = team, cooldownMs = cooldownS * 1000L, playDetectionTone = playDetectionTone) + } + } +} diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryInitializer.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryInitializer.kt new file mode 100644 index 0000000..494c1e4 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryInitializer.kt @@ -0,0 +1,13 @@ +package club.clubk.ktag.apps.sentry + +import android.content.Context +import androidx.startup.Initializer +import club.clubk.ktag.apps.core.SubAppRegistry + +class SentryInitializer : Initializer { + override fun create(context: Context) { + SubAppRegistry.register(SentrySubApp()) + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryPreferenceKeys.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryPreferenceKeys.kt new file mode 100644 index 0000000..87ea667 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryPreferenceKeys.kt @@ -0,0 +1,7 @@ +package club.clubk.ktag.apps.sentry + +object SentryPreferenceKeys { + const val TEAM = "sentry_team" + const val COOLDOWN_S = "sentry_cooldown_s" + const val PLAY_DETECTION_TONE = "sentry_play_detection_tone" +} diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentrySettingsActivity.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentrySettingsActivity.kt new file mode 100644 index 0000000..7210d84 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentrySettingsActivity.kt @@ -0,0 +1,18 @@ +package club.clubk.ktag.apps.sentry + +import android.content.Context +import android.content.Intent +import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity + +class SentrySettingsActivity : BaseSettingsActivity() { + companion object { + @JvmStatic + fun createIntent(context: Context): Intent { + return BaseSettingsActivity.createIntent( + context, + R.xml.preferences_sentry, + SentrySettingsActivity::class.java + ) + } + } +} diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentrySubApp.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentrySubApp.kt new file mode 100644 index 0000000..b751b15 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentrySubApp.kt @@ -0,0 +1,17 @@ +package club.clubk.ktag.apps.sentry + +import android.content.Context +import android.content.Intent +import club.clubk.ktag.apps.sharedservices.SettingsSubApp + +class SentrySubApp : SettingsSubApp { + override val id = "sentry" + override val name = "Sentry" + override val icon = R.drawable.ic_sentry + override val settingsPreferencesResId = R.xml.preferences_sentry + override val usesMqtt = false + + override fun createIntent(context: Context): Intent { + return Intent(context, SentryActivity::class.java) + } +} diff --git a/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryViewModel.kt b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryViewModel.kt new file mode 100644 index 0000000..b2916c2 --- /dev/null +++ b/subapp-sentry/src/main/java/club/clubk/ktag/apps/sentry/SentryViewModel.kt @@ -0,0 +1,273 @@ +package club.clubk.ktag.apps.sentry + +import android.app.Application +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.media.ToneGenerator +import kotlin.math.sin +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.graphics.Bitmap +import android.graphics.Matrix +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.lifecycle.AndroidViewModel +import club.clubk.ktag.apps.core.UsbSerialManager +import com.hoho.android.usbserial.driver.UsbSerialDriver +import com.google.mediapipe.framework.image.BitmapImageBuilder +import com.google.mediapipe.tasks.core.BaseOptions +import com.google.mediapipe.tasks.vision.core.RunningMode +import com.google.mediapipe.tasks.vision.objectdetector.ObjectDetector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class DetectionResult( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val confidence: Float +) + +class SentryViewModel(application: Application) : AndroidViewModel(application) { + + private val mainHandler = Handler(Looper.getMainLooper()) + + private val _connectionState = MutableStateFlow(UsbSerialManager.ConnectionState.DISCONNECTED) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _availableDevices = MutableStateFlow>(emptyList()) + val availableDevices: StateFlow> = _availableDevices.asStateFlow() + + private val _detectionResult = MutableStateFlow(null) + val detectionResult: StateFlow = _detectionResult.asStateFlow() + + private val _lastTriggerMs = MutableStateFlow(0L) + val lastTriggerMs: StateFlow = _lastTriggerMs.asStateFlow() + + private val _config = MutableStateFlow(SentryConfig.fromPreferences(application)) + val config: StateFlow = _config.asStateFlow() + + private val _statusMessage = MutableStateFlow(null) + val statusMessage: StateFlow = _statusMessage.asStateFlow() + + private val usbSerialManager = UsbSerialManager( + application, + object : UsbSerialManager.Listener { + override fun onDataReceived(data: ByteArray) { + // Sentry doesn't process incoming serial data + } + + override fun onConnectionStateChanged(state: UsbSerialManager.ConnectionState) { + mainHandler.post { _connectionState.value = state } + } + + override fun onError(message: String) { + mainHandler.post { _statusMessage.value = message } + } + + override fun onDevicesChanged(devices: List) { + mainHandler.post { _availableDevices.value = devices } + } + + override fun onAutoConnecting(driver: UsbSerialDriver) { + // Auto-connect is handled implicitly + } + }, + actionUsbPermission = "club.clubk.ktag.apps.sentry.USB_PERMISSION" + ) + + private val toneGenerator = ToneGenerator(AudioManager.STREAM_MUSIC, ToneGenerator.MAX_VOLUME) + @Volatile private var isDetectionTonePlaying = false + + // Model file must be placed in src/main/assets/efficientdet_lite0.tflite. + // Download from: https://tfhub.dev/tensorflow/lite-model/efficientdet/lite0/detection/metadata/1 + private val objectDetector: ObjectDetector by lazy { + val baseOptions = BaseOptions.builder() + .setModelAssetPath("efficientdet_lite0.tflite") + .build() + val options = ObjectDetector.ObjectDetectorOptions.builder() + .setBaseOptions(baseOptions) + .setRunningMode(RunningMode.IMAGE) + .setMaxResults(5) + .setScoreThreshold(0.5f) + .build() + ObjectDetector.createFromOptions(getApplication(), options) + } + + fun registerUsb() { + usbSerialManager.register() + } + + fun unregisterUsb() { + usbSerialManager.unregister() + } + + fun connect(driver: UsbSerialDriver) { + usbSerialManager.connect(driver) + } + + fun disconnect() { + usbSerialManager.disconnect() + } + + fun reloadConfig() { + _config.value = SentryConfig.fromPreferences(getApplication()) + } + + /** + * Returns an ImageAnalysis.Analyzer to be attached to the CameraX ImageAnalysis use case. + * The composable is responsible for creating and binding the use case. + */ + fun buildAnalyzer(): ImageAnalysis.Analyzer = ImageAnalysis.Analyzer { imageProxy -> + processImage(imageProxy) + } + + private fun processImage(imageProxy: ImageProxy) { + try { + val bitmap = imageProxy.toBitmap() + val rotationDegrees = imageProxy.imageInfo.rotationDegrees + val rotatedBitmap = if (rotationDegrees != 0) { + val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } else { + bitmap + } + + val mpImage = BitmapImageBuilder(rotatedBitmap).build() + val results = objectDetector.detect(mpImage) + + val personDetection = results.detections() + .filter { it.categories().any { cat -> cat.categoryName() == "person" } } + .maxByOrNull { it.categories().first { cat -> cat.categoryName() == "person" }.score() } + + if (personDetection == null) { + mainHandler.post { + _detectionResult.value = null + stopDetectionTone() + } + return + } + + val box = personDetection.boundingBox() + val w = rotatedBitmap.width.toFloat() + val h = rotatedBitmap.height.toFloat() + val result = DetectionResult( + left = (box.left / w).coerceIn(0f, 1f), + top = (box.top / h).coerceIn(0f, 1f), + right = (box.right / w).coerceIn(0f, 1f), + bottom = (box.bottom / h).coerceIn(0f, 1f), + confidence = personDetection.categories().first { it.categoryName() == "person" }.score() + ) + val intersectsCrosshair = result.left <= 0.5f && 0.5f <= result.right && + result.top <= 0.5f && 0.5f <= result.bottom + mainHandler.post { + _detectionResult.value = result + if (_config.value.playDetectionTone) startDetectionTone() else stopDetectionTone() + if (intersectsCrosshair) { + maybeTrigger() + } + } + } catch (e: Exception) { + Log.w(TAG, "Detection failed", e) + mainHandler.post { + _detectionResult.value = null + stopDetectionTone() + } + } finally { + imageProxy.close() + } + } + + private fun maybeTrigger() { + val cfg = _config.value + val now = System.currentTimeMillis() + if (now - _lastTriggerMs.value >= cfg.cooldownMs) { + _lastTriggerMs.value = now + val trigger_press_command = "KEvent 7 0\n" + usbSerialManager.write(trigger_press_command.toByteArray(Charsets.UTF_8)) + Log.i(TAG, "Triggered: $trigger_press_command") + playTriggerSound() + val trigger_release_command = "KEvent 8 0\n" + usbSerialManager.write(trigger_release_command.toByteArray(Charsets.UTF_8)) + } + } + + private fun startDetectionTone() { + if (isDetectionTonePlaying) return + isDetectionTonePlaying = true + + val sampleRate = 44100 + val frequency = 880.0 // A5 — one octave above the standard 440 Hz dial tone + val bufferSize = AudioTrack.getMinBufferSize( + sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT + ) + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build() + ) + .setBufferSizeInBytes(bufferSize) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + track.play() + + Thread { + val buffer = ShortArray(bufferSize / 2) + var phase = 0.0 + val phaseIncrement = 2.0 * Math.PI * frequency / sampleRate + try { + while (isDetectionTonePlaying) { + for (i in buffer.indices) { + buffer[i] = (Short.MAX_VALUE * sin(phase)).toInt().toShort() + phase += phaseIncrement + if (phase >= 2.0 * Math.PI) phase -= 2.0 * Math.PI + } + if (track.write(buffer, 0, buffer.size) < 0) break + } + } catch (e: Exception) { + Log.w(TAG, "Detection tone thread error", e) + } finally { + track.stop() + track.release() + } + }.start() + } + + private fun stopDetectionTone() { + isDetectionTonePlaying = false + // The tone thread sees the flag, exits its loop, and releases the AudioTrack in its finally block. + } + + private fun playTriggerSound() { + try { + toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 200) + } catch (e: Exception) { + Log.w(TAG, "Could not play trigger sound", e) + } + } + + override fun onCleared() { + super.onCleared() + stopDetectionTone() + objectDetector.close() + toneGenerator.release() + } + + companion object { + private const val TAG = "SentryViewModel" + } +} diff --git a/subapp-sentry/src/main/res/drawable/ic_sentry.png b/subapp-sentry/src/main/res/drawable/ic_sentry.png new file mode 100644 index 0000000..8ee5713 Binary files /dev/null and b/subapp-sentry/src/main/res/drawable/ic_sentry.png differ diff --git a/subapp-sentry/src/main/res/values/arrays.xml b/subapp-sentry/src/main/res/values/arrays.xml new file mode 100644 index 0000000..95918f9 --- /dev/null +++ b/subapp-sentry/src/main/res/values/arrays.xml @@ -0,0 +1,26 @@ + + + + Blue + Red + Purple + + + asblue + asred + aspurple + + + + 3 seconds + 5 seconds + 10 seconds + 30 seconds + + + 3 + 5 + 10 + 30 + + diff --git a/subapp-sentry/src/main/res/xml/device_filter.xml b/subapp-sentry/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..b6f8bb5 --- /dev/null +++ b/subapp-sentry/src/main/res/xml/device_filter.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/subapp-sentry/src/main/res/xml/preferences_sentry.xml b/subapp-sentry/src/main/res/xml/preferences_sentry.xml new file mode 100644 index 0000000..e507709 --- /dev/null +++ b/subapp-sentry/src/main/res/xml/preferences_sentry.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/subapp-terminal/README.md b/subapp-terminal/README.md new file mode 100644 index 0000000..e5176a6 --- /dev/null +++ b/subapp-terminal/README.md @@ -0,0 +1,132 @@ +# KTag Terminal Subapp + +A Jetpack Compose Android application providing a serial terminal for USB-connected KTag devices. + +## Overview + +The Terminal app connects to KTag devices via USB serial, allowing you to: + +- Send commands to the device +- View debug output and responses +- Monitor device state in real-time +- Share terminal logs for debugging + +This is primarily a development and debugging tool for working with KTag firmware. + +## Architecture + +The app follows a simple architecture with a manager class handling USB serial communication. + +``` +┌─────────────────────────────────────────────────────────┐ +│ TerminalActivity │ +│ (Compose Host) │ +│ • Device selection dropdown │ +│ • Terminal output display │ +│ • Command input field │ +└─────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────▼───────────────────────────────────┐ +│ UsbSerialManager │ +│ • USB device detection & permissions │ +│ • Serial port connection management │ +│ • Read/Write operations │ +│ • Connection state callbacks │ +└─────────────────────────────────────────────────────────┘ +``` + +## File Structure + +``` +src/main/java/club/clubk/ktag/apps/terminal/ +├── TerminalActivity.kt # Main terminal UI +├── UsbSerialManager.kt # USB serial communication +├── TerminalSubApp.kt # Subapp registration +└── TerminalInitializer.kt +``` + +## Features + +### Terminal Display + +- **Monospace font**: Proper terminal appearance +- **ANSI color support**: Full 256-color parsing +- **Directional indicators**: + - `>>` prefix for sent commands (blue) + - `--` prefix for system messages (gray) + - No prefix for received data (light gray) +- **Auto-scroll**: Follows new output +- **Line buffering**: Properly handles `\r\n` line endings +- **10,000 line limit**: Prevents memory issues + +### Device Connection + +- **Auto-detection**: Lists available USB serial devices +- **Hot-plug support**: Detects connect/disconnect events +- **Permission handling**: Automatic USB permission requests +- **Driver support**: Works with common USB-serial chips via usb-serial-for-android + +### Actions + +- **Send**: Transmit commands with Enter key or send button +- **Clear**: Reset terminal history +- **Share**: Export terminal log as text +- **Refresh**: Re-scan for USB devices + +## Key Components + +### TerminalActivity + +Main Compose screen containing: + +- `TopAppBar` with refresh, share, and clear actions +- Device selection `DropdownMenu` +- Connect/Disconnect `Button` +- `LazyColumn` terminal display with `TerminalLineRow` +- `OutlinedTextField` for command input + +### UsbSerialManager + +Handles all USB serial operations: + +```kotlin +interface Listener { + fun onDataReceived(data: ByteArray) + fun onConnectionStateChanged(state: ConnectionState) + fun onError(message: String) + fun onDevicesChanged(devices: List) +} + +enum class ConnectionState { + DISCONNECTED, + AWAITING_PERMISSION, + CONNECTING, + CONNECTED, + ERROR +} +``` + +### ANSI Color Parsing + +The `parseAnsi()` function supports: + +- Standard colors (30-37, 40-47) +- Bright colors (90-97) +- 256-color mode (38;5;n, 48;5;n) +- Bold, italic, underline, dim styles + +## Serial Settings + +| Parameter | Value | +|-----------|-------| +| Baud Rate | 115200 | +| Data Bits | 8 | +| Stop Bits | 1 | +| Parity | None | +| Line Ending | `\r\n` | + +## Dependencies + +- Jetpack Compose (Material3) +- usb-serial-for-android (USB serial drivers) +- Material Icons diff --git a/subapp-terminal/build.gradle.kts b/subapp-terminal/build.gradle.kts new file mode 100644 index 0000000..fddffac --- /dev/null +++ b/subapp-terminal/build.gradle.kts @@ -0,0 +1,44 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "club.clubk.ktag.apps.terminal" + 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) + + // USB Serial library + implementation("com.github.mik3y:usb-serial-for-android:3.10.0") +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} diff --git a/subapp-terminal/src/main/AndroidManifest.xml b/subapp-terminal/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cfaef27 --- /dev/null +++ b/subapp-terminal/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalActivity.kt b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalActivity.kt new file mode 100644 index 0000000..27e7a1a --- /dev/null +++ b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalActivity.kt @@ -0,0 +1,582 @@ +package club.clubk.ktag.apps.terminal + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import club.clubk.ktag.apps.core.UsbSerialManager +import club.clubk.ktag.apps.terminal.ui.theme.KTagDarkGray +import club.clubk.ktag.apps.terminal.ui.theme.KTagYellow +import club.clubk.ktag.apps.terminal.ui.theme.TerminalTheme +import com.hoho.android.usbserial.driver.UsbSerialDriver +import kotlinx.coroutines.launch + +private val TerminalFontFamily = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold) +) + +class TerminalActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TerminalTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + TerminalScreen() + } + } + } + } +} + +data class TerminalLine( + val timestamp: Long, + val direction: Direction, + val content: String +) { + enum class Direction { RX, TX, SYSTEM } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TerminalScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + var connectionState by remember { mutableStateOf(UsbSerialManager.ConnectionState.DISCONNECTED) } + var inputText by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + var availableDevices by remember { mutableStateOf>(emptyList()) } + var selectedDevice by remember { mutableStateOf(null) } + var showDeviceMenu by remember { mutableStateOf(false) } + val terminalLines = remember { mutableStateListOf() } + val mainHandler = remember { Handler(Looper.getMainLooper()) } + val rxBuffer = remember { StringBuilder() } + + val usbSerialManager = remember { + UsbSerialManager(context, object : UsbSerialManager.Listener { + override fun onDataReceived(data: ByteArray) { + val text = String(data, Charsets.UTF_8) + mainHandler.post { + rxBuffer.append(text) + // Split on newlines and emit complete lines + var newlineIndex: Int + while (rxBuffer.indexOf('\n').also { newlineIndex = it } != -1) { + var lineEnd = newlineIndex + // Handle \r\n by stripping the \r as well + if (lineEnd > 0 && rxBuffer[lineEnd - 1] == '\r') { + lineEnd-- + } + val lineContent = rxBuffer.substring(0, lineEnd) + rxBuffer.delete(0, newlineIndex + 1) + terminalLines.add(TerminalLine(System.currentTimeMillis(), TerminalLine.Direction.RX, lineContent)) + } + // Limit buffer size + while (terminalLines.size > MAX_LINES) { + terminalLines.removeAt(0) + } + } + } + + override fun onConnectionStateChanged(state: UsbSerialManager.ConnectionState) { + mainHandler.post { + connectionState = state + if (state == UsbSerialManager.ConnectionState.CONNECTED) { + rxBuffer.clear() + terminalLines.add( + TerminalLine( + System.currentTimeMillis(), + TerminalLine.Direction.SYSTEM, + "Connected to ${selectedDevice?.device?.deviceName ?: "device"}" + ) + ) + } else if (state == UsbSerialManager.ConnectionState.DISCONNECTED) { + // Flush any remaining buffered data + if (rxBuffer.isNotEmpty()) { + terminalLines.add(TerminalLine(System.currentTimeMillis(), TerminalLine.Direction.RX, rxBuffer.toString())) + rxBuffer.clear() + } + terminalLines.add( + TerminalLine( + System.currentTimeMillis(), + TerminalLine.Direction.SYSTEM, + "Disconnected" + ) + ) + } + } + } + + override fun onError(message: String) { + mainHandler.post { + errorMessage = message + terminalLines.add( + TerminalLine( + System.currentTimeMillis(), + TerminalLine.Direction.SYSTEM, + "Error: $message" + ) + ) + } + } + + override fun onDevicesChanged(devices: List) { + mainHandler.post { + availableDevices = devices + if (selectedDevice != null && devices.none { it.device.deviceId == selectedDevice?.device?.deviceId }) { + selectedDevice = null + } + } + } + + override fun onAutoConnecting(driver: UsbSerialDriver) { + mainHandler.post { + selectedDevice = driver + } + } + }, actionUsbPermission = "club.clubk.ktag.apps.terminal.USB_PERMISSION") + } + + DisposableEffect(usbSerialManager) { + usbSerialManager.register() + onDispose { + usbSerialManager.unregister() + } + } + + // Auto-scroll when new lines are added + LaunchedEffect(terminalLines.size) { + if (terminalLines.isNotEmpty()) { + listState.animateScrollToItem(terminalLines.size - 1) + } + } + + fun sendCommand() { + if (inputText.isNotBlank() && connectionState == UsbSerialManager.ConnectionState.CONNECTED) { + val command = inputText + terminalLines.add(TerminalLine(System.currentTimeMillis(), TerminalLine.Direction.TX, command)) + usbSerialManager.write((command + "\r\n").toByteArray()) + inputText = "" + // Limit buffer size + while (terminalLines.size > MAX_LINES) { + terminalLines.removeAt(0) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("KTag Terminal") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = KTagDarkGray, + titleContentColor = KTagYellow, + actionIconContentColor = KTagYellow + ), + actions = { + IconButton(onClick = { usbSerialManager.refreshDevices() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh devices") + } + IconButton( + onClick = { + val text = terminalLines.joinToString("\n") { line -> + val prefix = when (line.direction) { + TerminalLine.Direction.RX -> "" + TerminalLine.Direction.TX -> ">> " + TerminalLine.Direction.SYSTEM -> "-- " + } + prefix + stripAnsi(line.content) + } + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + }, + enabled = terminalLines.isNotEmpty() + ) { + Icon(Icons.Default.Share, contentDescription = "Share terminal history") + } + IconButton(onClick = { terminalLines.clear() }) { + Icon(Icons.Default.Clear, contentDescription = "Clear terminal") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding() + ) { + // Connection controls + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { showDeviceMenu = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = selectedDevice?.device?.deviceName + ?: if (availableDevices.isEmpty()) "No devices" else "Select device", + maxLines = 1 + ) + } + DropdownMenu( + expanded = showDeviceMenu, + onDismissRequest = { showDeviceMenu = false } + ) { + if (availableDevices.isEmpty()) { + DropdownMenuItem( + text = { Text("No USB serial devices found") }, + onClick = { showDeviceMenu = false } + ) + } else { + availableDevices.forEach { driver -> + DropdownMenuItem( + text = { + Text("${driver.device.deviceName} (${driver.javaClass.simpleName.removeSuffix("Driver")})") + }, + onClick = { + selectedDevice = driver + showDeviceMenu = false + } + ) + } + } + } + } + + Button( + onClick = { + when (connectionState) { + UsbSerialManager.ConnectionState.CONNECTED -> { + usbSerialManager.disconnect() + } + UsbSerialManager.ConnectionState.DISCONNECTED, + UsbSerialManager.ConnectionState.ERROR -> { + selectedDevice?.let { usbSerialManager.connect(it) } + } + else -> { /* Connecting/awaiting permission - do nothing */ } + } + }, + enabled = selectedDevice != null && + connectionState != UsbSerialManager.ConnectionState.CONNECTING && + connectionState != UsbSerialManager.ConnectionState.AWAITING_PERMISSION, + colors = ButtonDefaults.buttonColors( + containerColor = if (connectionState == UsbSerialManager.ConnectionState.CONNECTED) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.primary + ) + ) { + Text( + when (connectionState) { + UsbSerialManager.ConnectionState.CONNECTED -> "Disconnect" + UsbSerialManager.ConnectionState.CONNECTING -> "Connecting..." + UsbSerialManager.ConnectionState.AWAITING_PERMISSION -> "Requesting..." + else -> "Connect" + } + ) + } + } + + // Terminal display + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF323031)) + .padding(8.dp) + ) { + items(terminalLines) { line -> + TerminalLineRow(line) + } + } + + // Input area + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Enter command...", fontFamily = TerminalFontFamily) }, + singleLine = true, + enabled = connectionState == UsbSerialManager.ConnectionState.CONNECTED, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { sendCommand() }), + textStyle = androidx.compose.ui.text.TextStyle(fontFamily = TerminalFontFamily) + ) + + IconButton( + onClick = { sendCommand() }, + enabled = connectionState == UsbSerialManager.ConnectionState.CONNECTED && inputText.isNotBlank() + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + } + } + } + } +} + +@Composable +fun TerminalLineRow(line: TerminalLine) { + val (prefix, defaultColor) = when (line.direction) { + TerminalLine.Direction.RX -> "" to Color(0xFFCCCCCC) // Light gray for RX + TerminalLine.Direction.TX -> ">> " to Color(0xFF4D6CFA) // KTag Blue for TX + TerminalLine.Direction.SYSTEM -> "-- " to Color(0xFF808080) // Gray for system + } + + val styledText = if (line.direction == TerminalLine.Direction.RX) { + parseAnsi(line.content, defaultColor) + } else { + buildAnnotatedString { + pushStyle(SpanStyle(color = defaultColor)) + append(prefix) + append(line.content) + pop() + } + } + + Text( + text = styledText, + style = androidx.compose.ui.text.TextStyle( + fontFamily = TerminalFontFamily, + fontSize = 12.sp + ), + modifier = Modifier.padding(vertical = 1.dp) + ) +} + +private fun parseAnsi(text: String, defaultColor: Color): AnnotatedString { + return buildAnnotatedString { + var currentColor = defaultColor + var currentBgColor: Color? = null + var isBold = false + var isItalic = false + var isUnderline = false + var isDim = false + + val ansiPattern = Regex("\u001B\\[([0-9;]*)m") + var lastEnd = 0 + + ansiPattern.findAll(text).forEach { match -> + // Append text before this escape sequence + if (match.range.first > lastEnd) { + pushStyle(SpanStyle( + color = if (isDim) currentColor.copy(alpha = 0.5f) else currentColor, + background = currentBgColor ?: Color.Transparent, + fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (isItalic) FontStyle.Italic else FontStyle.Normal, + textDecoration = if (isUnderline) TextDecoration.Underline else TextDecoration.None + )) + append(text.substring(lastEnd, match.range.first)) + pop() + } + lastEnd = match.range.last + 1 + + // Parse the escape codes + val codes = match.groupValues[1].split(";").mapNotNull { it.toIntOrNull() } + if (codes.isEmpty()) { + // Reset + currentColor = defaultColor + currentBgColor = null + isBold = false + isItalic = false + isUnderline = false + isDim = false + } else { + var i = 0 + while (i < codes.size) { + when (codes[i]) { + 0 -> { + currentColor = defaultColor + currentBgColor = null + isBold = false + isItalic = false + isUnderline = false + isDim = false + } + 1 -> isBold = true + 2 -> isDim = true + 3 -> isItalic = true + 4 -> isUnderline = true + 22 -> { isBold = false; isDim = false } + 23 -> isItalic = false + 24 -> isUnderline = false + // Standard foreground colors (30-37) + 30 -> currentColor = Color(0xFF000000) // Black + 31 -> currentColor = Color(0xFFCD3131) // Red + 32 -> currentColor = Color(0xFF0DBC79) // Green + 33 -> currentColor = Color(0xFFE5E510) // Yellow + 34 -> currentColor = Color(0xFF2472C8) // Blue + 35 -> currentColor = Color(0xFFBC3FBC) // Magenta + 36 -> currentColor = Color(0xFF11A8CD) // Cyan + 37 -> currentColor = Color(0xFFE5E5E5) // White + 39 -> currentColor = defaultColor // Default foreground + // Bright foreground colors (90-97) + 90 -> currentColor = Color(0xFF666666) // Bright black (gray) + 91 -> currentColor = Color(0xFFF14C4C) // Bright red + 92 -> currentColor = Color(0xFF23D18B) // Bright green + 93 -> currentColor = Color(0xFFF5F543) // Bright yellow + 94 -> currentColor = Color(0xFF3B8EEA) // Bright blue + 95 -> currentColor = Color(0xFFD670D6) // Bright magenta + 96 -> currentColor = Color(0xFF29B8DB) // Bright cyan + 97 -> currentColor = Color(0xFFFFFFFF) // Bright white + // Standard background colors (40-47) + 40 -> currentBgColor = Color(0xFF000000) + 41 -> currentBgColor = Color(0xFFCD3131) + 42 -> currentBgColor = Color(0xFF0DBC79) + 43 -> currentBgColor = Color(0xFFE5E510) + 44 -> currentBgColor = Color(0xFF2472C8) + 45 -> currentBgColor = Color(0xFFBC3FBC) + 46 -> currentBgColor = Color(0xFF11A8CD) + 47 -> currentBgColor = Color(0xFFE5E5E5) + 49 -> currentBgColor = null // Default background + // 256-color mode (38;5;n or 48;5;n) + 38 -> { + if (i + 2 < codes.size && codes[i + 1] == 5) { + currentColor = ansi256ToColor(codes[i + 2], defaultColor) + i += 2 + } + } + 48 -> { + if (i + 2 < codes.size && codes[i + 1] == 5) { + currentBgColor = ansi256ToColor(codes[i + 2], defaultColor) + i += 2 + } + } + } + i++ + } + } + } + + // Append remaining text + if (lastEnd < text.length) { + pushStyle(SpanStyle( + color = if (isDim) currentColor.copy(alpha = 0.5f) else currentColor, + background = currentBgColor ?: Color.Transparent, + fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (isItalic) FontStyle.Italic else FontStyle.Normal, + textDecoration = if (isUnderline) TextDecoration.Underline else TextDecoration.None + )) + append(text.substring(lastEnd)) + pop() + } + } +} + +private fun ansi256ToColor(code: Int, defaultColor: Color): Color { + return when { + code < 16 -> { + // Standard colors + val standardColors = listOf( + Color(0xFF000000), Color(0xFFCD3131), Color(0xFF0DBC79), Color(0xFFE5E510), + Color(0xFF2472C8), Color(0xFFBC3FBC), Color(0xFF11A8CD), Color(0xFFE5E5E5), + Color(0xFF666666), Color(0xFFF14C4C), Color(0xFF23D18B), Color(0xFFF5F543), + Color(0xFF3B8EEA), Color(0xFFD670D6), Color(0xFF29B8DB), Color(0xFFFFFFFF) + ) + standardColors.getOrElse(code) { defaultColor } + } + code < 232 -> { + // 216-color cube (6x6x6) + val index = code - 16 + val r = (index / 36) * 51 + val g = ((index / 6) % 6) * 51 + val b = (index % 6) * 51 + Color(0xFF000000 or (r shl 16).toLong() or (g shl 8).toLong() or b.toLong()) + } + else -> { + // Grayscale (24 shades) + val gray = (code - 232) * 10 + 8 + Color(0xFF000000 or (gray shl 16).toLong() or (gray shl 8).toLong() or gray.toLong()) + } + } +} + +private fun stripAnsi(text: String): String { + return text.replace(Regex("\u001B\\[[0-9;]*m"), "") +} + +private const val MAX_LINES = 10000 diff --git a/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalInitializer.kt b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalInitializer.kt new file mode 100644 index 0000000..059b643 --- /dev/null +++ b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalInitializer.kt @@ -0,0 +1,13 @@ +package club.clubk.ktag.apps.terminal + +import android.content.Context +import androidx.startup.Initializer +import club.clubk.ktag.apps.core.SubAppRegistry + +class TerminalInitializer : Initializer { + override fun create(context: Context) { + SubAppRegistry.register(TerminalSubApp()) + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalSubApp.kt b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalSubApp.kt new file mode 100644 index 0000000..c1dcce1 --- /dev/null +++ b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/TerminalSubApp.kt @@ -0,0 +1,15 @@ +package club.clubk.ktag.apps.terminal + +import android.content.Context +import android.content.Intent +import club.clubk.ktag.apps.core.SubApp + +class TerminalSubApp : SubApp { + override val id = "terminal" + override val name = "Terminal" + override val icon = R.drawable.ic_terminal + + override fun createIntent(context: Context): Intent { + return Intent(context, TerminalActivity::class.java) + } +} diff --git a/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/ui/theme/Color.kt b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/ui/theme/Color.kt new file mode 100644 index 0000000..984a129 --- /dev/null +++ b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/ui/theme/Color.kt @@ -0,0 +1,13 @@ +package club.clubk.ktag.apps.terminal.ui.theme + +// Re-export KTag colors used directly in TerminalActivity +val KTagDarkGray = club.clubk.ktag.apps.core.ui.theme.KTagDarkGray +val KTagYellow = club.clubk.ktag.apps.core.ui.theme.KTagYellow + +// Terminal color aliases for clarity in module-specific code +val TerminalGreen = club.clubk.ktag.apps.core.ui.theme.KTagGreen +val TerminalBlue = club.clubk.ktag.apps.core.ui.theme.KTagBlue +val TerminalRed = club.clubk.ktag.apps.core.ui.theme.KTagRed +val TerminalYellow = club.clubk.ktag.apps.core.ui.theme.KTagYellow +val TerminalPurple = club.clubk.ktag.apps.core.ui.theme.KTagPurple +val TerminalDarkGray = club.clubk.ktag.apps.core.ui.theme.KTagDarkGray diff --git a/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/ui/theme/Theme.kt b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/ui/theme/Theme.kt new file mode 100644 index 0000000..c9d806f --- /dev/null +++ b/subapp-terminal/src/main/java/club/clubk/ktag/apps/terminal/ui/theme/Theme.kt @@ -0,0 +1,52 @@ +package club.clubk.ktag.apps.terminal.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 TerminalTheme( + 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 + ) +} diff --git a/subapp-terminal/src/main/res/drawable/ic_terminal.png b/subapp-terminal/src/main/res/drawable/ic_terminal.png new file mode 100644 index 0000000..71b0152 Binary files /dev/null and b/subapp-terminal/src/main/res/drawable/ic_terminal.png differ diff --git a/subapp-terminal/src/main/res/font/jetbrains_mono.xml b/subapp-terminal/src/main/res/font/jetbrains_mono.xml new file mode 100644 index 0000000..464043d --- /dev/null +++ b/subapp-terminal/src/main/res/font/jetbrains_mono.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/subapp-terminal/src/main/res/font/jetbrains_mono_bold.ttf b/subapp-terminal/src/main/res/font/jetbrains_mono_bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/subapp-terminal/src/main/res/font/jetbrains_mono_bold.ttf differ diff --git a/subapp-terminal/src/main/res/font/jetbrains_mono_regular.ttf b/subapp-terminal/src/main/res/font/jetbrains_mono_regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/subapp-terminal/src/main/res/font/jetbrains_mono_regular.ttf differ diff --git a/subapp-terminal/src/main/res/xml/device_filter.xml b/subapp-terminal/src/main/res/xml/device_filter.xml new file mode 100644 index 0000000..b6f8bb5 --- /dev/null +++ b/subapp-terminal/src/main/res/xml/device_filter.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +