Initial public release.

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

49
subapp-mine/README.md Normal file
View file

@ -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

View file

@ -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<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.VIBRATE" />
<application>
<activity
android:name=".MineActivity"
android:exported="false"
android:label="@string/app_name"
android:theme="@style/AppTheme" />
<activity
android:name=".MineSettingsActivity"
android:exported="false"
android:label="@string/action_settings"
android:parentActivityName=".MineActivity"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".MineActivity"/>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="club.clubk.ktag.apps.mine.MineInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>

View file

@ -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
}
}

View file

@ -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<String>) {
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<String>,
onRequestPermissions: () -> Unit
) {
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "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")
}
}
}

View file

@ -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<Unit> {
override fun create(context: Context) {
SubAppRegistry.register(MineSubApp())
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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<DeviceModel>()
val devices: List<DeviceModel> get() = _devices
// Mine state machine
var mineState by mutableStateOf<MineState>(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<String, DeviceInfo>) {
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()
}
}

View file

@ -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)
)
}
}
}
}

View file

@ -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)
}
}

View file

@ -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<DeviceModel?>(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
)
}
}

View file

@ -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

View file

@ -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
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_target_team_list_titles">
<item>Red</item>
<item>Blue</item>
<item>All</item>
</string-array>
<string-array name="pref_target_team_list_values">
<item>1</item>
<item>2</item>
<item>3</item>
</string-array>
</resources>

View file

@ -0,0 +1,5 @@
<resources>
<string name="app_name">KTag Mine</string>
<string name="hello_world">Hello world!</string>
<string name="action_settings">Settings</string>
</resources>

View file

@ -0,0 +1,8 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory android:title="Mine Settings">
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint='"-120" (far) to "0" (nearby); try "-60"'
android:inputType="numberSigned"
android:key="mine_min_rssi"
android:summary="%s dBm"
android:title="Minimum RSSI (dBm) for Tagging" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Damage to apply (e.g. 100)"
android:inputType="number"
android:key="mine_damage"
android:summary="%s"
android:title="Damage Applied" />
<ListPreference
android:entries="@array/pref_target_team_list_titles"
android:entryValues="@array/pref_target_team_list_values"
android:defaultValue="3"
android:key="mine_target_team"
android:title="Target Team"
android:summary="Target %s" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Seconds before mine rearms (e.g. 5)"
android:inputType="number"
android:key="mine_rearm_time"
android:summary="%s seconds"
android:title="Time to Rearm" />
</PreferenceCategory>
</PreferenceScreen>