diff --git a/README.md b/README.md
index 8f71fde..0ab5659 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Android Konfigurator
+
+
## Overview
This software is used for configuring and initiating KTag games.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 61d3699..c33ad3a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,13 +2,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/AppState.kt b/app/src/main/java/club/clubk/ktag/konfigurator/AppState.kt
new file mode 100644
index 0000000..9088331
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/AppState.kt
@@ -0,0 +1,12 @@
+package club.clubk.ktag.konfigurator
+
+sealed class AppState {
+ object TitleScreen : AppState()
+ object GameSettings : AppState()
+ object TeamSettings : AppState()
+ object PregameTimer : AppState()
+ object Countdown : AppState()
+ object GameTimer : AppState()
+ object GameOver : AppState()
+ object WrapUp : AppState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/BleManager.kt b/app/src/main/java/club/clubk/ktag/konfigurator/BleManager.kt
new file mode 100644
index 0000000..4a4035e
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/BleManager.kt
@@ -0,0 +1,322 @@
+package club.clubk.ktag.konfigurator
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanSettings
+import android.util.Log
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private const val TAG_BLE_SCAN = "BLE Scanner"
+private const val TAG_BLE_AD = "BLE Advertiser"
+
+private const val SCAN_HOLDOFF_PERIOD_MS: Long = 5000 // 5 seconds
+
+class BleManager private constructor(context: Context) {
+ // Use applicationContext to prevent memory leaks
+ private val appContext: Context = context.applicationContext
+
+ // Bluetooth-related properties
+ private val bluetoothManager: BluetoothManager =
+ appContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+ private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
+ private val bluetoothLeAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
+ private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
+ private var currentAdvertiseCallback: AdvertiseCallback? = null
+ private var currentScanCallback: ScanCallback? = null
+ private var isAdvertising: Boolean = false
+ private var isScanning: Boolean = false
+
+ // Timestamp for the last scan stop
+ @Volatile
+ private var lastScanStopTimeMs: Long = 0
+
+ // Coroutine scope for managing delays
+ private val bleManagerScope = CoroutineScope(Dispatchers.Main + Job()) // Use Main dispatcher for UI-related callbacks if needed, or Default for background work
+ private var scanStartJob: Job? = null
+
+
+ // Singleton instance
+ companion object {
+ @Volatile
+ private var instance: BleManager? = null
+
+ /**
+ * Gets or creates the BleManager instance
+ *
+ * @param context The context used to get the application context
+ * @return BleManager instance
+ */
+ fun getInstance(context: Context): BleManager {
+ // Double-checked locking pattern
+ return instance ?: synchronized(this) {
+ instance ?: BleManager(context).also { instance = it }
+ }
+ }
+ }
+
+ // Utility function to check if Bluetooth is available and enabled
+ private fun isBluetoothAvailable(): Boolean {
+ return bluetoothAdapter != null && bluetoothAdapter.isEnabled
+ }
+
+ @SuppressLint("MissingPermission")
+ fun startAdvertising(data: ByteArray, advertiseCallback: AdvertiseCallback) {
+ stopAdvertising()
+ if (!isBluetoothAvailable()) {
+ Log.e(TAG_BLE_AD, "Bluetooth is not enabled!")
+ throw IllegalStateException("Bluetooth is not enabled!")
+ }
+
+ currentAdvertiseCallback = advertiseCallback
+
+ val settings = AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+ .setConnectable(false)
+ .setTimeout(0)
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .build()
+
+ // Calculate available space for manufacturer data
+ // 2 bytes for manufacturer ID
+ // Maximum total payload is 31 bytes
+ // Format: [Length (1 byte)] [Type (1 byte)] [Manufacturer ID (2 bytes)] [Data (remaining bytes)]
+ val maxManufacturerDataSize = 27 // 31 - 4 bytes overhead
+
+ val truncatedData = if (data.size > maxManufacturerDataSize) {
+ Log.w(
+ TAG_BLE_AD,
+ "Data exceeded maximum size (${data.size} > $maxManufacturerDataSize bytes), truncating..."
+ )
+ data.copyOfRange(0, maxManufacturerDataSize)
+ } else {
+ data
+ }
+
+ Log.d(TAG_BLE_AD, "Advertisement structure:")
+ Log.d(TAG_BLE_AD, "- Total payload max: 31 bytes")
+ Log.d(TAG_BLE_AD, "- Overhead: 4 bytes (Length: 1, Type: 1, Manufacturer ID: 2)")
+ Log.d(TAG_BLE_AD, "- Available for manufacturer data: $maxManufacturerDataSize bytes")
+ Log.d(TAG_BLE_AD, "- Actual data size: ${truncatedData.size} bytes")
+ Log.d(TAG_BLE_AD, "- Data: ${truncatedData.joinToString(" ") { String.format("%02X", it) }}")
+
+ val advertiseData = AdvertiseData.Builder()
+ .addManufacturerData(0xFFFF, truncatedData)
+ .build()
+
+ Log.i(
+ TAG_BLE_AD, "Starting advertisement with settings: Mode=${settings.mode}, " +
+ "TxPower=${settings.txPowerLevel}, Connectable=${settings.isConnectable}"
+ )
+
+ try {
+ bluetoothLeAdvertiser?.startAdvertising(settings, advertiseData, advertiseCallback)
+ isAdvertising = true
+ } catch (e: SecurityException) {
+ Log.e(TAG_BLE_AD, "Permission missing for starting advertisement", e)
+ advertiseCallback.onStartFailure(AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR) // Notify caller
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun stopAdvertising() {
+ Log.d(TAG_BLE_AD, "Attempting to stop advertising...")
+ currentAdvertiseCallback?.let { callback ->
+ try {
+ bluetoothLeAdvertiser?.stopAdvertising(callback)
+ Log.i(TAG_BLE_AD, "Advertisement stopping process initiated.")
+ } catch (e: SecurityException) {
+ Log.e(TAG_BLE_AD, "Permission missing for stopping advertisement", e)
+ } finally {
+ currentAdvertiseCallback = null // Clear callback regardless of success/failure to stop
+ isAdvertising = false
+ }
+ } ?: Log.d(TAG_BLE_AD, "No active advertisement to stop or advertiser is null.")
+ // isAdvertising should be reliably set to false when stop is called or if no active ad
+ if (currentAdvertiseCallback == null) isAdvertising = false
+ }
+
+
+ @SuppressLint("MissingPermission")
+ fun startScanning(scanCallback: ScanCallback) {
+ // Cancel any pending scan start operation
+ scanStartJob?.cancel()
+
+ scanStartJob = bleManagerScope.launch {
+ val currentTimeMs = System.currentTimeMillis()
+ val timeSinceLastScanStopMs = currentTimeMs - lastScanStopTimeMs
+
+ if (isScanning) { // If already scanning with a *different* callback, stop it first
+ Log.d(TAG_BLE_SCAN, "Already scanning, but a new scan request received. Stopping current scan first.")
+ internalStopScan() // Use an internal stop that doesn't update lastScanStopTimeMs yet
+ }
+
+ if (lastScanStopTimeMs > 0 && timeSinceLastScanStopMs < SCAN_HOLDOFF_PERIOD_MS) {
+ val delayNeededMs = SCAN_HOLDOFF_PERIOD_MS - timeSinceLastScanStopMs
+ Log.i(TAG_BLE_SCAN, "Scan holdoff active. Delaying scan start by ${delayNeededMs}ms")
+ delay(delayNeededMs)
+ }
+
+ // After potential delay, stop any existing scan (e.g. if one was running with a different callback)
+ // This also ensures that if stopScanning() was called very recently, we respect that.
+ if (currentScanCallback != null && currentScanCallback != scanCallback) {
+ internalStopScan() // Stop previous scan if a new one is requested with different callback
+ }
+
+
+ currentScanCallback = scanCallback // Set the new callback
+
+ val filter = ScanFilter.Builder()
+ .setManufacturerData(
+ 0xFFFF, // Manufacturer ID
+ byteArrayOf(0x4B, 0x54, 0x61, 0x67) // "KTag"
+ )
+ .build()
+ val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
+
+ if (!isBluetoothAvailable()) {
+ Log.e(TAG_BLE_SCAN, "Bluetooth is not enabled! Cannot start scan.")
+ scanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) // Notify caller
+ return@launch
+ }
+
+ Log.i(TAG_BLE_SCAN, "Starting scan...")
+ try {
+ bluetoothLeScanner?.startScan(listOf(filter), settings, scanCallback)
+ isScanning = true
+ } catch (e: SecurityException) {
+ Log.e(TAG_BLE_SCAN, "Permission missing for starting scan", e)
+ scanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) // Notify caller
+ isScanning = false // Ensure state is correct
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun stopScanning() {
+ // Cancel any pending scan start job, as we are now explicitly stopping.
+ scanStartJob?.cancel()
+ internalStopScan()
+ }
+
+ // Internal stop scan function to avoid recursive calls and manage state correctly
+ @SuppressLint("MissingPermission")
+ private fun internalStopScan() {
+ Log.d(TAG_BLE_SCAN, "Attempting to stop scanning (internal)...")
+ currentScanCallback?.let { callback ->
+ try {
+ bluetoothLeScanner?.stopScan(callback)
+ Log.i(TAG_BLE_SCAN, "Scan stopping process initiated.")
+ } catch (e: SecurityException) {
+ Log.e(TAG_BLE_SCAN, "Permission missing for stopping scan", e)
+ } finally {
+ // This is a good place to record the stop time
+ lastScanStopTimeMs = System.currentTimeMillis()
+ currentScanCallback = null // Clear callback
+ isScanning = false // Update scanning state
+ }
+ } ?: Log.d(TAG_BLE_SCAN, "No active scan to stop or scanner is null.")
+ // Ensure scanning state is false if no callback
+ if (currentScanCallback == null) isScanning = false
+ }
+
+
+ suspend fun configureDevice(configPacket: ParametersPacket): Boolean {
+ // Ensure configureDevice uses the managed startScanning/stopScanning
+ return withTimeout(5000) { // Timeout for the whole configuration operation
+ val advertisementDetected = CompletableDeferred()
+ var tempAdvertiseCallback: AdvertiseCallback? = null
+ var tempScanCallback: ScanCallback? = null
+
+ try {
+ tempAdvertiseCallback = object : AdvertiseCallback() {
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
+ super.onStartSuccess(settingsInEffect)
+ Log.d(TAG_BLE_AD,"Config advertisement started successfully.")
+ // Only start scanning AFTER advertisement has successfully started
+ tempScanCallback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ Log.d(TAG_BLE_SCAN, ">>> ScanCallback.onScanResult ENTERED")
+ result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
+ if (data.size >= 4 &&
+ data[0] == 0x4B.toByte() && // K
+ data[1] == 0x54.toByte() && // T
+ data[2] == 0x61.toByte() && // a
+ data[3] == 0x67.toByte() // g
+ ) {
+ Log.d(TAG_BLE_SCAN, "KTag packet scanned, checking for parameter packet..." + data[4].toString() + " " + data[12].toString())
+ val scannedPacket = byteArrayToKTagPacket(data)
+ if (scannedPacket is ParametersPacket) {
+ Log.d(TAG_BLE_SCAN, "Parameter packet scanned, checking for parameter match...")
+ if (result.device.address == configPacket.targetAddress
+ && scannedPacket.subtype == 3 // Ensure this is the ACK subtype
+ && scannedPacket.key1 == configPacket.key1 // Example: check relevant params
+ && scannedPacket.value1 == configPacket.value1
+ && scannedPacket.key2 == configPacket.key2
+ && scannedPacket.value2 == configPacket.value2
+ ) {
+ Log.i(TAG_BLE_SCAN, "Parameters match, configuration successful for ${result.device.address}!")
+ if (!advertisementDetected.isCompleted) {
+ advertisementDetected.complete(true)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ Log.e(TAG_BLE_SCAN, "Config scan failed with error: $errorCode")
+ if (!advertisementDetected.isCompleted) {
+ advertisementDetected.complete(false)
+ }
+ }
+ }
+ // Use the main startScanning function which includes holdoff logic
+ this@BleManager.startScanning(tempScanCallback!!)
+ }
+
+ override fun onStartFailure(errorCode: Int) {
+ super.onStartFailure(errorCode)
+ Log.e(TAG_BLE_AD, "Failed to start config advertisement, error: $errorCode")
+ if (!advertisementDetected.isCompleted) {
+ advertisementDetected.complete(false)
+ }
+ }
+ }
+
+ Log.d(TAG_BLE_AD, "Sending configuration packet to ${configPacket.targetAddress}...")
+ this@BleManager.startAdvertising(kTagPacketToByteArray(configPacket),
+ tempAdvertiseCallback
+ )
+
+ val success = advertisementDetected.await() // Wait for scan result or ad failure
+ success
+
+ } catch (e: TimeoutCancellationException) {
+ Log.e(TAG_BLE_SCAN, "Config confirmation timed out for ${configPacket.targetAddress}")
+ false
+ } finally {
+ Log.d(TAG_BLE_SCAN, "Cleaning up configureDevice resources for ${configPacket.targetAddress}")
+ // Stop the specific advertising and scanning session for this configuration attempt
+ // Check callbacks before stopping to ensure they were set
+ tempAdvertiseCallback?.let { this@BleManager.stopAdvertising() } // Uses the main stop which clears currentAdvertiseCallback
+ tempScanCallback?.let { this@BleManager.stopScanning() } // Uses the main stop which clears currentScanCallback and sets holdoff time
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/ConfigurationProgress.kt b/app/src/main/java/club/clubk/ktag/konfigurator/ConfigurationProgress.kt
new file mode 100644
index 0000000..14badb2
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/ConfigurationProgress.kt
@@ -0,0 +1,7 @@
+package club.clubk.ktag.konfigurator
+
+data class ConfigurationProgress(
+ val configuredCount: Int,
+ val totalCount: Int,
+ val isComplete: Boolean
+)
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/Device.kt b/app/src/main/java/club/clubk/ktag/konfigurator/Device.kt
new file mode 100644
index 0000000..8b607aa
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/Device.kt
@@ -0,0 +1,24 @@
+package club.clubk.ktag.konfigurator
+
+import java.util.UUID
+
+data class Device(val uuid: UUID = UUID.randomUUID(),
+ var name: String = "Unknown Device",
+ var address: String = "FF:FF:FF:FF:FF:FF",
+ var deviceType : Int? = null,
+ var team : Int? = null,
+ var playerID : Int? = null,
+ var deviceState: DeviceState? = null
+ ) {
+
+ fun deviceTypeName(): String {
+ return when(deviceType) {
+ 0 -> "Little Boy BLuE"
+ 1 -> "2020TPC"
+ 2 -> "Mobile App"
+ 3 -> "32ESPecial"
+ else -> "Unknown Device Type"
+ }
+ }
+}
+
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/DeviceState.kt b/app/src/main/java/club/clubk/ktag/konfigurator/DeviceState.kt
new file mode 100644
index 0000000..22ea035
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/DeviceState.kt
@@ -0,0 +1,8 @@
+package club.clubk.ktag.konfigurator
+
+sealed class DeviceState {
+ object Configurable : DeviceState()
+ object Ready : DeviceState()
+ object Playing : DeviceState()
+ object WrapUp : DeviceState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/GameConfig.kt b/app/src/main/java/club/clubk/ktag/konfigurator/GameConfig.kt
new file mode 100644
index 0000000..ccf6988
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/GameConfig.kt
@@ -0,0 +1,9 @@
+package club.clubk.ktag.konfigurator
+
+data class GameConfig(var name: String = "Default",
+ var gameLength: Int = 600000,
+ var pregameLength: Int = 60000,
+ var numRounds: Int = 2,
+ var maxHealth: Int = 10,
+ var numBombs: Int = 1 // Special Weapons Received on Game Reentry
+)
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/KTagPacket.kt b/app/src/main/java/club/clubk/ktag/konfigurator/KTagPacket.kt
new file mode 100644
index 0000000..b6a293c
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/KTagPacket.kt
@@ -0,0 +1,365 @@
+package club.clubk.ktag.konfigurator
+
+val kTagByte = byteArrayOf(
+ 0x4B.toByte(), // K
+ 0x54.toByte(), // T
+ 0x61.toByte(), // a
+ 0x67.toByte() // g
+)
+
+// Data classes for packet parsing
+sealed class KTagPacket {
+ abstract val type: Int
+ abstract val typeName: String
+ abstract val eventNumber: Int
+}
+
+data class InstigateGamePacket(
+ override val type: Int,
+ override val typeName: String = "Instigate Game",
+ override val eventNumber: Int,
+ val gameLength: Int,
+ val countdownTime: Int
+) : KTagPacket()
+
+data class EventPacket(
+ override val type: Int,
+ override val typeName: String = "Event",
+ override val eventNumber: Int,
+ val targetAddress: String,
+ val eventId: Int,
+ val eventData: Int
+) : KTagPacket()
+
+data class TagPacket(
+ override val type: Int,
+ override val typeName: String = "Tag",
+ override val eventNumber: Int,
+ val txPower: Int,
+ val protocol: Int,
+ val teamId: Int,
+ val playerId: Int,
+ val damage: Int,
+ val color: Int,
+ val targetAddress: String
+) : KTagPacket()
+
+data class ConsolePacket(
+ override val type: Int,
+ override val typeName: String = "Console",
+ override val eventNumber: Int,
+ val consoleString: String
+) : KTagPacket()
+
+data class StatusPacket(
+ override val type: Int,
+ override val typeName: String = "Status",
+ override val eventNumber: Int,
+ val txPower: Int,
+ val protocol: Int,
+ val teamId: Int,
+ val playerId: Int,
+ val health: Int,
+ val maxHealth: Int,
+ val primaryColor: Int,
+ val secondaryColor: Int,
+ val systemKState: Int
+) : KTagPacket()
+
+data class ParametersPacket(
+ override val type: Int,
+ override val typeName: String = "Parameters",
+ override val eventNumber: Int,
+ val targetAddress: String,
+ val subtype: Int,
+ val key1: Int,
+ val value1: Int,
+ val key2: Int,
+ val value2: Int
+) : KTagPacket()
+
+data class HelloPacket(
+ override val type: Int,
+ override val typeName: String = "Hello",
+ override val eventNumber: Int,
+ val majorVersion: Int,
+ val minorVersion: Int,
+ val deviceType: Int,
+ val teamId: Int,
+ val deviceName: String
+) : KTagPacket()
+
+fun byteArrayToKTagPacket(data: ByteArray): KTagPacket {
+ val type = data[4].toInt()
+ val eventNumber = data[5].toInt()
+
+ return when (type) {
+ 0x01 -> InstigateGamePacket(
+ type = type,
+ eventNumber = eventNumber,
+ gameLength = bytesToInt(data, 6, 4),
+ countdownTime = bytesToInt(data, 10, 4)
+ )
+
+ 0x02 -> EventPacket(
+ type = type,
+ eventNumber = eventNumber,
+ targetAddress = bytesToMacAddress(data, 6),
+ eventId = bytesToInt(data, 12, 4),
+ eventData = bytesToInt(data, 16, 4)
+ )
+
+ 0x03 -> TagPacket(
+ type = type,
+ eventNumber = eventNumber,
+ txPower = data[6].toInt(),
+ protocol = data[7].toInt(),
+ teamId = data[8].toInt(),
+ playerId = data[9].toInt(),
+ damage = bytesToInt(data, 10, 2),
+ color = bytesToInt(data, 12, 4),
+ targetAddress = bytesToMacAddress(data, 16)
+ )
+
+ 0x04 -> ConsolePacket(
+ type = type,
+ eventNumber = eventNumber,
+ consoleString = data.slice(6..26).toByteArray().let { bytes ->
+ // Find the position of the first null byte if any
+ val nullPos = bytes.indexOfFirst { it == 0.toByte() }
+ // If there's a null byte, take only up to that position, otherwise take all bytes
+ if (nullPos >= 0) {
+ bytes.slice(0 until nullPos).toByteArray()
+ } else {
+ bytes
+ }.toString(Charsets.US_ASCII).trim()
+ }
+ )
+
+ 0x05 -> StatusPacket(
+ type = type,
+ eventNumber = eventNumber,
+ txPower = data[6].toInt(),
+ protocol = data[7].toInt(),
+ teamId = data[8].toInt(),
+ playerId = data[8].toInt(),
+ health = bytesToInt(data, 10, 2),
+ maxHealth = bytesToInt(data, 12, 2),
+ primaryColor = bytesToInt(data, 14, 4),
+ secondaryColor = bytesToInt(data, 18, 4),
+ systemKState = data[22].toInt()
+ )
+
+ 0x06 -> ParametersPacket(
+ type = type,
+ eventNumber = eventNumber,
+ targetAddress = bytesToMacAddress(data, 6),
+ subtype = data[12].toInt(),
+ key1 = bytesToInt(data, 13, 2),
+ value1 = bytesToInt(data, 15, 4),
+ key2 = bytesToInt(data, 19, 2),
+ value2 = bytesToInt(data, 21, 4)
+ )
+
+ 0x07 -> HelloPacket(
+ type = type,
+ eventNumber = eventNumber,
+ majorVersion = data[6].toInt(),
+ minorVersion = data[7].toInt(),
+ deviceType = bytesToInt(data, 8, 2),
+ teamId = data[10].toInt(),
+ deviceName = data.slice(11..26).toByteArray().let { bytes ->
+ // Find the position of the first null byte if any
+ val nullPos = bytes.indexOfFirst { it == 0.toByte() }
+ // If there's a null byte, take only up to that position, otherwise take all bytes
+ if (nullPos >= 0) {
+ bytes.slice(0 until nullPos).toByteArray()
+ } else {
+ bytes
+ }.toString(Charsets.US_ASCII).trim()
+ }
+ )
+
+ else -> StatusPacket(
+ type = type,
+ typeName = "Unknown",
+ eventNumber = eventNumber,
+ txPower = 0,
+ protocol = 0,
+ teamId = 0,
+ playerId = 0,
+ health = 0,
+ maxHealth = 0,
+ primaryColor = 0,
+ secondaryColor = 0,
+ systemKState = 0
+ )
+ }
+}
+
+fun kTagPacketToByteArray(kTagPacket: KTagPacket): ByteArray {
+ var payload: ByteArray = byteArrayOf(0x00.toByte())
+ when (kTagPacket) {
+ is ParametersPacket -> {
+ val addressBytes = macAddressToBytes(kTagPacket.targetAddress)
+ payload = byteArrayOf(
+ 0x06.toByte(), // Packet Type: Parameter
+ (kTagPacket.eventNumber and 0xFF).toByte(), // Event counter
+
+ *addressBytes,
+
+ (kTagPacket.subtype and 0xFF).toByte(),
+
+ (kTagPacket.key1 and 0xFF).toByte(),
+ ((kTagPacket.key1 shr 8) and 0xFF).toByte(),
+
+ (kTagPacket.value1 and 0xFF).toByte(),
+ ((kTagPacket.value1 shr 8) and 0xFF).toByte(),
+ ((kTagPacket.value1 shr 16) and 0xFF).toByte(),
+ ((kTagPacket.value1 shr 24) and 0xFF).toByte(),
+
+ (kTagPacket.key2 and 0xFF).toByte(),
+ ((kTagPacket.key2 shr 8) and 0xFF).toByte(),
+
+ (kTagPacket.value2 and 0xFF).toByte(),
+ ((kTagPacket.value2 shr 8) and 0xFF).toByte(),
+ ((kTagPacket.value2 shr 16) and 0xFF).toByte(),
+ ((kTagPacket.value2 shr 24) and 0xFF).toByte()
+ ) + ByteArray(2) { 0xFF.toByte() }
+ }
+ is ConsolePacket -> {
+ // something
+ }
+ is EventPacket -> {
+ val addressBytes = macAddressToBytes(kTagPacket.targetAddress)
+ payload = byteArrayOf(
+ 0x02.toByte(), // Packet Type: Event
+ (kTagPacket.eventNumber and 0xFF).toByte(), // Event counter
+
+ *addressBytes,
+
+ (kTagPacket.eventId and 0xFF).toByte(),
+ ((kTagPacket.eventId shr 8) and 0xFF).toByte(),
+ ((kTagPacket.eventId shr 16) and 0xFF).toByte(),
+ ((kTagPacket.eventId shr 24) and 0xFF).toByte(), // Event ID
+
+ (kTagPacket.eventData and 0xFF).toByte(),
+ ((kTagPacket.eventData shr 8) and 0xFF).toByte(),
+ ((kTagPacket.eventData shr 16) and 0xFF).toByte(),
+ ((kTagPacket.eventData shr 24) and 0xFF).toByte() // Event data
+ ) + ByteArray(7) { 0xFF.toByte() }
+ }
+ is HelloPacket -> {
+ // something
+ }
+ is InstigateGamePacket -> {
+ payload = byteArrayOf(
+ 0x01.toByte(), // Packet Type: Instigate Game
+ (kTagPacket.eventNumber and 0xFF).toByte(), // Event counter
+
+ (kTagPacket.gameLength and 0xFF).toByte(),
+ ((kTagPacket.gameLength shr 8) and 0xFF).toByte(),
+ ((kTagPacket.gameLength shr 16) and 0xFF).toByte(),
+ ((kTagPacket.gameLength shr 24) and 0xFF).toByte(), // Game length
+
+ (kTagPacket.countdownTime and 0xFF).toByte(),
+ ((kTagPacket.countdownTime shr 8) and 0xFF).toByte(),
+ ((kTagPacket.countdownTime shr 16) and 0xFF).toByte(),
+ ((kTagPacket.countdownTime shr 24) and 0xFF).toByte() // Time until countdown
+ ) + ByteArray(21) { 0xFF.toByte() }
+ }
+ is StatusPacket -> {
+ // something
+ }
+ is TagPacket -> {
+ // something
+ }
+ }
+ return kTagByte + payload
+}
+
+// Helper function to convert bytes to integers
+private fun bytesToInt(data: ByteArray, offset: Int, length: Int): Int {
+ var result = 0
+ for (i in 0 until length) {
+ result = result or ((data[offset + i].toInt() and 0xFF) shl (8 * i))
+ }
+ return result
+}
+
+// Helper function to convert bytes to MAC address string
+private fun bytesToMacAddress(data: ByteArray, offset: Int): String {
+ return (0..5).joinToString(":") {
+ String.format("%02X", data[offset + it])
+ }
+}
+
+private fun macAddressToBytes(address: String): ByteArray {
+ val cleanAddress = address.replace(":", "").replace("-", "")
+
+ // Check if the address is valid (12 hex characters = 6 bytes)
+ if (cleanAddress.length != 12) {
+ throw IllegalArgumentException("Invalid BLE address length")
+ }
+
+ // Convert the hex string to bytes. Reverse the bytes to little-endian format.
+ val bytes = ByteArray(6)
+ var j = 5
+ for (i in 0 until 6) {
+ val hexByte = cleanAddress.substring(i * 2, i * 2 + 2)
+ bytes[j] = hexByte.toInt(16).toByte()
+ j--
+ }
+
+ return bytes
+}
+
+class InstigatePacketGenerator {
+ private var eventCount = 0
+
+ fun generatePacket(gameLength: Int, timeUntilCountdown: Int): InstigateGamePacket {
+ val newInstigateGamePacket = InstigateGamePacket(
+ type = 1, // instigate
+ eventNumber = eventCount,
+ gameLength = gameLength,
+ countdownTime = timeUntilCountdown
+ )
+ eventCount++
+ return newInstigateGamePacket
+ }
+}
+
+class EventPacketGenerator {
+ private var eventCount = 0
+
+ fun generatePacket(targetAddress: String, eventId: Int = 0, eventData: Int = 0): EventPacket {
+ val newEventPacket = EventPacket(
+ type = 2, // event
+ eventNumber = eventCount,
+ targetAddress = targetAddress,
+ eventId = eventId,
+ eventData = eventData
+ )
+ eventCount++
+ return newEventPacket
+ }
+}
+
+class ParametersPacketGenerator {
+ private var eventCount = 0
+
+ fun generatePacket(targetAddress: String, subtype: Int,
+ key1: Int = 0, value1: Int = 0,
+ key2: Int = 0, value2: Int = 0): ParametersPacket {
+ val newParametersPacket = ParametersPacket(
+ type = 6, // parameters
+ eventNumber = eventCount,
+ targetAddress = targetAddress,
+ subtype = subtype,
+ key1 = key1, value1 = value1,
+ key2 = key2, value2 = value2
+ )
+ eventCount++
+ return newParametersPacket
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/MainActivity.kt b/app/src/main/java/club/clubk/ktag/konfigurator/MainActivity.kt
index 27b07de..d67932b 100644
--- a/app/src/main/java/club/clubk/ktag/konfigurator/MainActivity.kt
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/MainActivity.kt
@@ -1,47 +1,655 @@
package club.clubk.ktag.konfigurator
+import android.Manifest
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
+import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.core.content.ContextCompat
+// import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import club.clubk.ktag.konfigurator.ui.theme.KonfiguratorTheme
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private const val TAG = "Konfigurator"
class MainActivity : ComponentActivity() {
+ private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(
+ Manifest.permission.BLUETOOTH_ADVERTISE,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.ACCESS_FINE_LOCATION, // Required for BLE operations
+ Manifest.permission.ACCESS_COARSE_LOCATION // Required for BLE operations
+ )
+ } 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")
+ mainScreen()
+ } else {
+ // Show which permissions were denied
+ val deniedPermissions = permissions.filter { !it.value }.keys
+ Log.w(TAG, "Some permissions denied: $deniedPermissions")
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ PermissionDeniedScreen(
+ deniedPermissions = deniedPermissions.toList()
+ ) { requestPermissions() }
+ }
+ }
+ }
+ }
+ }
+
+ private fun checkAndRequestPermissions() {
+ Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
+ if (hasRequiredPermissions()) {
+ Log.i(TAG, "All required permissions already granted")
+ mainScreen()
+ } else {
+ Log.i(TAG, "Requesting permissions")
+ requestPermissions()
+ }
+ }
+
+ private fun hasRequiredPermissions(): Boolean {
+ return requiredPermissions.all { permission ->
+ ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ private fun requestPermissions() {
+ Log.d(TAG, "Launching permission request")
+ permissionLauncher.launch(requiredPermissions)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- enableEdgeToEdge()
+ checkAndRequestPermissions()
+ // enableEdgeToEdge()
+ }
+
+ private fun mainScreen() {
setContent {
KonfiguratorTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
- )
- }
+ StateMachine(StateMachineViewModel(applicationContext))
}
}
}
}
@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
+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 = "The following permissions are required:",
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ deniedPermissions.forEach { permission ->
+ Text(
+ text = "• ${permission.split(".").last()}",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ }
+ Button(
+ onClick = {
+ Log.d(TAG, "Permission request button clicked")
+ onRequestPermissions()
+ },
+ modifier = Modifier.padding(top = 16.dp)
+ ) {
+ Text("Grant Permissions")
+ }
+ }
+}
+
+@Composable
+fun StateMachine(stateMachine: StateMachineViewModel) { // stateMachine: StateMachineViewModel = viewModel()
+ val appState by stateMachine.appState.collectAsState()
+
+ Surface(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ when (appState) {
+ AppState.TitleScreen -> TitleScreen(stateMachine)
+ AppState.GameSettings -> GameSettingsScreen(stateMachine)
+ AppState.TeamSettings -> TeamSettingsScreen(stateMachine)
+ AppState.PregameTimer -> PregameTimerScreen(stateMachine)
+ AppState.Countdown -> CountdownScreen(stateMachine)
+ AppState.GameTimer -> GameTimerScreen(stateMachine)
+ AppState.GameOver -> GameOverScreen(stateMachine)
+ AppState.WrapUp -> Text("Wrap Up")
+ }
+ }
+ }
+}
+
+// ------------
+// TITLE SCREEN
+//
+
+@Composable
+fun BigLogo() {
+ Icon(
+ painter = painterResource(id = R.drawable.konfigurator),
+ contentDescription = "Konfigurator Logo",
+ modifier = Modifier.size(192.dp),
+ tint = Color.Unspecified
)
}
-@Preview(showBackground = true)
@Composable
-fun GreetingPreview() {
- KonfiguratorTheme {
- Greeting("Android")
+fun TitleScreen(stateMachine: StateMachineViewModel) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Konfigurator", style = MaterialTheme.typography.headlineMedium)
+ BigLogo()
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = { stateMachine.nextState() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("New Game")
+ }
+}
+
+// -------------
+// GAME SETTINGS
+// -------------
+
+@Composable
+fun GameSettingsScreen(stateMachine: StateMachineViewModel) {
+ val currentGameConfig by stateMachine.currentGameConfig.collectAsState()
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Game Settings",
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(16.dp)
+ )
+ GameConfigEditor(oldGameConfig = currentGameConfig,
+ onSave = {
+ stateMachine.updateGameConfig(it)
+ stateMachine.nextState()
+ })
+ }
+}
+
+@Composable
+fun GameConfigEditor(oldGameConfig: GameConfig,
+ onSave: (GameConfig) -> Unit,
+ modifier: Modifier = Modifier) {
+ key(oldGameConfig) {
+ var name by rememberSaveable(oldGameConfig.name) { mutableStateOf(oldGameConfig.name) }
+ var gameLength by rememberSaveable(oldGameConfig.gameLength) { mutableStateOf(oldGameConfig.gameLength.toString()) }
+ var pregameLength by rememberSaveable(oldGameConfig.pregameLength) { mutableStateOf(oldGameConfig.pregameLength.toString()) }
+ var numRounds by rememberSaveable(oldGameConfig.numRounds) { mutableStateOf(oldGameConfig.numRounds.toString()) }
+ var maxHealth by rememberSaveable(oldGameConfig.maxHealth) { mutableStateOf(oldGameConfig.maxHealth.toString()) }
+ var numBombs by rememberSaveable(oldGameConfig.numBombs) { mutableStateOf(oldGameConfig.numBombs.toString()) }
+
+ // For tracking validation errors
+ var gameLengthError by rememberSaveable { mutableStateOf(false) }
+ var pregameLengthError by rememberSaveable { mutableStateOf(false) }
+ var numRoundsError by rememberSaveable { mutableStateOf(false) }
+ var maxHealthError by rememberSaveable { mutableStateOf(false) }
+ var numBombsError by rememberSaveable { mutableStateOf(false) }
+
+ // Function to validate the form
+ fun validateForm(): Boolean {
+ gameLengthError = !isValidInteger(gameLength)
+ pregameLengthError = !isValidInteger(pregameLength)
+ numRoundsError = !isValidInteger(numRounds)
+ maxHealthError = !isValidInteger(maxHealth)
+ numBombsError = !isValidInteger(numBombs)
+
+ return !gameLengthError && !pregameLengthError && !numRoundsError && !maxHealthError && !numBombsError
+ }
+
+ // Function to update the data class when values change
+ fun saveGameConfig() {
+ // Only update if all integer fields are valid
+ if (validateForm()) {
+ val newGameConfig = GameConfig(
+ name = name,
+ gameLength = gameLength.toIntOrNull() ?: oldGameConfig.gameLength,
+ pregameLength = pregameLength.toIntOrNull() ?: oldGameConfig.pregameLength,
+ numRounds = numRounds.toIntOrNull() ?: oldGameConfig.numRounds,
+ maxHealth = maxHealth.toIntOrNull() ?: oldGameConfig.maxHealth,
+ numBombs = numBombs.toIntOrNull() ?: oldGameConfig.numBombs
+ )
+ onSave(newGameConfig)
+ }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // String value field
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ },
+ label = { Text("Name") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ // Integer value fields
+ IntegerTextField(
+ value = gameLength,
+ onValueChange = {
+ gameLength = it
+ // gameLengthError = !isValidInteger(it)
+ },
+ label = "Game Length",
+ isError = gameLengthError
+ )
+
+ IntegerTextField(
+ value = pregameLength,
+ onValueChange = {
+ pregameLength = it
+ // pregameLengthError = !isValidInteger(it)
+ },
+ label = "Pregame Length",
+ isError = pregameLengthError
+ )
+
+ IntegerTextField(
+ value = numRounds,
+ onValueChange = {
+ numRounds = it
+ // numRoundsError = !isValidInteger(it)
+ },
+ label = "Number Of Rounds",
+ isError = numRoundsError
+ )
+
+ IntegerTextField(
+ value = maxHealth,
+ onValueChange = {
+ maxHealth = it
+ // maxHealthError = !isValidInteger(it)
+ },
+ label = "Max Health",
+ isError = maxHealthError
+ )
+
+ IntegerTextField(
+ value = numBombs,
+ onValueChange = {
+ numBombs = it
+ // numBombsError = !isValidInteger(it)
+ },
+ label = "Number Of Bombs",
+ isError = numBombsError
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Button( // Save button
+ onClick = { saveGameConfig() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Confirm")
+ }
+ }
+}
+
+// Helper function to validate integer input
+private fun isValidInteger(input: String): Boolean {
+ return input.toIntOrNull() != null || input.isEmpty()
+}
+
+// Helper composable for integer input fields
+@Composable
+private fun IntegerTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ isError: Boolean
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ isError = isError,
+ supportingText = if (isError) {
+ { Text("Please enter a valid integer") }
+ } else null,
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+// -------------
+// TEAM SETTINGS
+// -------------
+
+@Composable
+fun TeamSettingsScreen(stateMachine: StateMachineViewModel) {
+ Log.d("MAINACTIVITY", "recomposing team setting screen")
+ val coroutineScope = rememberCoroutineScope()
+ val devices by stateMachine.devices.collectAsState()
+ val allDevicesReady by stateMachine.allDevicesReady.collectAsState()
+ Scaffold(
+ // Bottom bar that stays fixed at the bottom
+ bottomBar = {
+ BottomAppBar(
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 8.dp
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (allDevicesReady) {
+ Button(
+ onClick = { stateMachine.nextState() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Start Game")
+ }
+ } else {
+ SideBySideButtons(
+ "Apply",
+ "Ready",
+ onLeftButtonClick = { coroutineScope.launch { stateMachine.configureDevices() } },
+ onRightButtonClick = { stateMachine.readyAllDevices() }
+ )
+ }
+ }
+ }
+ }
+ ) { paddingValues ->
+ DeviceList(stateMachine, devices, Modifier.fillMaxSize().padding(paddingValues))
+ }
+}
+
+@Composable
+fun DeviceList(stateMachine: StateMachineViewModel, devices: List, modifier: Modifier) {
+ LazyColumn(modifier = modifier) {
+ items(devices) { device ->
+ DeviceCard(stateMachine, device)
+ }
+ }
+}
+
+@Composable
+fun SideBySideButtons(
+ leftButtonText: String,
+ rightButtonText: String,
+ onLeftButtonClick: () -> Unit,
+ onRightButtonClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp) // Add space between buttons
+ ) {
+ Button(
+ onClick = onLeftButtonClick,
+ modifier = Modifier.weight(1f) // Takes up half of the available width
+ ) {
+ Text(text = leftButtonText)
+ }
+
+ Button(
+ onClick = onRightButtonClick,
+ modifier = Modifier.weight(1f) // Takes up half of the available width
+ ) {
+ Text(text = rightButtonText)
+ }
+ }
+}
+
+@Composable
+fun DeviceCard(stateMachine: StateMachineViewModel, device: Device) {
+ val backgroundColor = when (device.team) {
+ 0 -> Color.Magenta
+ 1 -> Color.Red
+ 2 -> Color.Blue
+ else -> MaterialTheme.colorScheme.surface // Default color
+ }
+ Card(modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ onClick = { stateMachine.cycleDeviceTeam(device) },
+ colors = CardDefaults.cardColors(
+ containerColor = backgroundColor
+ )) {
+ Text(device.name + " | " + device.deviceTypeName())
+ Text(if(device.deviceState == DeviceState.Ready) {"READY"} else {"NOT READY"})
+ }
+}
+
+// -------------
+// PREGAME TIMER
+// -------------
+
+fun formatTimeToMinutesSeconds(remainingSeconds: Int): String {
+ val minutes = remainingSeconds / 60
+ val seconds = remainingSeconds % 60
+
+ // Format to ensure seconds always shows with two digits (e.g., "01" instead of "1")
+ return "$minutes:${seconds.toString().padStart(2, '0')}"
+}
+
+@Composable
+fun CountdownTimer(timeInMillis: Long, onCountdownComplete: () -> Unit) {
+ // Store remaining time as state
+ var remainingTime by remember { mutableLongStateOf(timeInMillis) }
+ val initialTime = remember { timeInMillis }
+
+ // Use LaunchedEffect to create a coroutine that runs the countdown
+ LaunchedEffect(key1 = timeInMillis) {
+ // Reset the timer if the input time changes
+ remainingTime = timeInMillis
+
+ // Continue counting down until we reach zero
+ while (remainingTime > 0) {
+ delay(1000) // Update every second
+ remainingTime -= 1000
+ Log.d("CountdownTimer", "Remaining time: ${remainingTime / 1000} seconds")
+ }
+
+ // Log when countdown reaches zero
+ Log.d("CountdownTimer", "Countdown finished!")
+ onCountdownComplete()
+ }
+
+ val remainingSeconds = (remainingTime / 1000).toInt()
+ val progress = if (initialTime > 0) remainingTime.toFloat() / initialTime.toFloat() else 0f
+ val formattedTime = formatTimeToMinutesSeconds(remainingSeconds)
+
+ // UI to display the countdown
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Display remaining time in seconds
+ Text(
+ text = formattedTime,
+ fontSize = 36.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ // Display progress bar
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Composable
+fun PregameTimerScreen(stateMachine: StateMachineViewModel) {
+ val currentGameConfig by stateMachine.currentGameConfig.collectAsState()
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Game Starts In",
+ style = MaterialTheme.typography.headlineMedium
+ )
+ CountdownTimer(currentGameConfig.pregameLength.toLong(), onCountdownComplete = {
+ stateMachine.nextState()
+ })
+ }
+}
+
+// ---------
+// COUNTDOWN
+// ---------
+
+@Composable
+fun CountdownScreen(stateMachine: StateMachineViewModel) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CountdownTimer(6000.toLong(), onCountdownComplete = {
+ stateMachine.nextState()
+ })
+ }
+}
+
+// ----------
+// GAME TIMER
+// ----------
+
+@Composable
+fun GameTimerScreen(stateMachine: StateMachineViewModel) {
+ val currentGameConfig by stateMachine.currentGameConfig.collectAsState()
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Game In Progress",
+ style = MaterialTheme.typography.headlineMedium
+ )
+
+ CountdownTimer(currentGameConfig.gameLength.toLong(), onCountdownComplete = {
+ stateMachine.nextState()
+ })
+
+ Button(
+ onClick = { stateMachine.nextState() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("End Game")
+ }
+ }
+}
+
+// ---------
+// GAME OVER
+// ---------
+
+@Composable
+fun GameOverScreen(stateMachine: StateMachineViewModel) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Game Over",
+ style = MaterialTheme.typography.headlineMedium
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Icon(
+ imageVector = androidx.compose.material.icons.Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(100.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = { stateMachine.nextState() },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Play Again")
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/MultiDeviceConfigurator.kt b/app/src/main/java/club/clubk/ktag/konfigurator/MultiDeviceConfigurator.kt
new file mode 100644
index 0000000..088a7e7
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/MultiDeviceConfigurator.kt
@@ -0,0 +1,116 @@
+package club.clubk.ktag.konfigurator
+
+import android.util.Log
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+// Assuming ParametersPacket and BleManager are defined as in your existing code
+
+class MultiDeviceConfigurator(private val bleManager: BleManager) {
+
+ // Mutex to ensure only one device configuration (advertise + scan for ack) happens at a time
+ private val bleOperationMutex = Mutex()
+
+ /**
+ * Configures a list of devices sequentially in terms of BLE operations,
+ * but the overall process can be managed as part of a larger concurrent workflow.
+ *
+ * @param deviceConfigs List of ParametersPacket for each device to configure.
+ * @param onDeviceConfigured A callback invoked for each device with its address and success status.
+ * @param onProgressUpdate A callback to report overall progress (e.g., "Configuring device X of Y").
+ * @return A map of device addresses to their configuration success status.
+ */
+ suspend fun configureMultipleDevices(
+ deviceConfigs: List,
+ onDeviceConfigured: suspend (deviceAddress: String, success: Boolean) -> Unit,
+ onProgressUpdate: suspend (configuredCount: Int, totalCount: Int) -> Unit
+ ): Map {
+ val results = mutableMapOf()
+ var configuredCount = 0
+ val totalCount = deviceConfigs.size
+
+ onProgressUpdate(configuredCount, totalCount)
+
+ for (configPacket in deviceConfigs) {
+ // Ensure only one BLE configuration operation (advertising + specific scan)
+ // happens at a time across all calls that might use this shared BleManager.
+ // If configureDevice itself manages a Mutex for its specific startAdvertising/startScanning,
+ // this outer Mutex might be redundant for that part, but it's good for clarity
+ // if other BLE operations could interfere.
+ // For now, let's assume configureDevice is a self-contained operation.
+ // The critical part is that `bleManager.configureDevice` itself does a full
+ // advertise -> scan -> stop cycle.
+
+ var success = false
+ try {
+ // The bleOperationMutex ensures that if multiple coroutines call
+ // configureMultipleDevices, or if other parts of your app try to use
+ // bleManager for advertising/scanning, they don't interfere with the
+ // current device's configuration sequence.
+ bleOperationMutex.withLock {
+ Log.i(
+ "MultiDeviceConfigurator",
+ "Attempting to configure ${configPacket.targetAddress}"
+ )
+ // The existing configureDevice already has its own timeout.
+ success = bleManager.configureDevice(configPacket)
+ }
+ } catch (e: Exception) {
+ // Catch any unexpected exceptions during the configuration of a single device
+ Log.e(
+ "MultiDeviceConfigurator",
+ "Exception configuring ${configPacket.targetAddress}",
+ e
+ )
+ success = false
+ }
+
+ results[configPacket.targetAddress] = success
+ configuredCount++
+ onDeviceConfigured(configPacket.targetAddress, success)
+ onProgressUpdate(configuredCount, totalCount)
+
+ if (success) {
+ Log.i(
+ "MultiDeviceConfigurator",
+ "Successfully configured ${configPacket.targetAddress}"
+ )
+ } else {
+ Log.w(
+ "MultiDeviceConfigurator",
+ "Failed to configure ${configPacket.targetAddress}"
+ )
+ }
+
+ // Optional: Add a small delay between configuring devices if peripherals need time
+ // delay(100)
+ }
+ return results
+ }
+}
+
+// --- Example Usage (e.g., in a ViewModel or a Service) ---
+suspend fun exampleUsage(bleManager: BleManager, allDeviceConfigs: List) {
+ val configurator = MultiDeviceConfigurator(bleManager)
+
+ Log.d("App", "Starting configuration for ${allDeviceConfigs.size} devices...")
+
+ // This whole block can be launched in a specific coroutine scope (e.g., viewModelScope)
+ // The configureMultipleDevices function itself is sequential for BLE ops,
+ // but the call to it can be non-blocking for the UI.
+ val configurationResults = configurator.configureMultipleDevices(
+ deviceConfigs = allDeviceConfigs,
+ onDeviceConfigured = { deviceAddress, success ->
+ Log.d("App", "Device $deviceAddress configuration result: $success")
+ // Update UI for this specific device
+ },
+ onProgressUpdate = { configured, total ->
+ Log.d("App", "Progress: $configured / $total devices configured.")
+ // Update overall progress UI
+ }
+ )
+
+ Log.d("App", "All device configurations attempted. Results: $configurationResults")
+ // Process overall results
+}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/Player.kt b/app/src/main/java/club/clubk/ktag/konfigurator/Player.kt
new file mode 100644
index 0000000..1a6da53
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/Player.kt
@@ -0,0 +1,10 @@
+package club.clubk.ktag.konfigurator
+
+import java.util.UUID
+
+data class Player(val uuid: UUID = UUID.randomUUID()) {
+ var name : String = "Anonymous"
+ var id : Int = 0
+ var numWins : Int = 0
+ var numLosses : Int = 0
+}
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/SoundManager.kt b/app/src/main/java/club/clubk/ktag/konfigurator/SoundManager.kt
new file mode 100644
index 0000000..743491c
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/SoundManager.kt
@@ -0,0 +1,57 @@
+package club.clubk.ktag.konfigurator
+
+import android.content.Context
+import android.media.AudioAttributes
+import android.media.AudioManager
+import android.media.SoundPool
+import android.os.Build
+import android.util.Log
+
+class SoundManager(private val context: Context) {
+ private var soundPool: SoundPool? = null
+ private var doubleChirpID: Int = 0
+ private var isLoaded = false
+
+ init {
+ // Initialize the SoundPool
+ soundPool = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ SoundPool.Builder()
+ .setMaxStreams(3) // Allow up to 3 simultaneous sounds
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ )
+ .build()
+ } else {
+ @Suppress("DEPRECATION")
+ SoundPool(3, AudioManager.STREAM_MUSIC, 0)
+ }
+
+ // Set up load listener to know when sound is ready to play
+ soundPool?.setOnLoadCompleteListener { _, _, status ->
+ isLoaded = status == 0
+ }
+
+ // Load the click sound (place your click.wav file in res/raw)
+ doubleChirpID = soundPool?.load(context, R.raw.double_chirp, 1) ?: 0
+ }
+
+ fun playDoubleChirp() {
+ if (isLoaded) {
+ // Play the sound with:
+ // leftVolume, rightVolume (1f = full volume)
+ // priority (0 = lowest)
+ // loop (0 = no loop)
+ // rate (1f = normal playback speed)
+ soundPool?.play(doubleChirpID, 1f, 1f, 0, 0, 1f)
+ }
+ }
+
+ // Important: Release resources when no longer needed
+ fun release() {
+ soundPool?.release()
+ soundPool = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/club/clubk/ktag/konfigurator/StateMachineViewModel.kt b/app/src/main/java/club/clubk/ktag/konfigurator/StateMachineViewModel.kt
new file mode 100644
index 0000000..aeedd61
--- /dev/null
+++ b/app/src/main/java/club/clubk/ktag/konfigurator/StateMachineViewModel.kt
@@ -0,0 +1,348 @@
+package club.clubk.ktag.konfigurator
+
+import android.annotation.SuppressLint
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import android.content.Context
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+private const val TAG_BLE_SCAN = "BLE Scanner"
+private const val TAG_BLE_AD = "BLE Advertiser"
+private const val TAG_STATE_MACHINE = "StateMachineVM"
+
+class StateMachineViewModel(context: Context) : ViewModel() {
+
+ private val _appState = MutableStateFlow(AppState.TitleScreen)
+ val appState: StateFlow = _appState.asStateFlow()
+
+ private val _devices = MutableStateFlow>(emptyList())
+ val devices: StateFlow> = _devices.asStateFlow()
+
+ private val _allDevicesReady = MutableStateFlow(false)
+ val allDevicesReady = _allDevicesReady.asStateFlow()
+
+ private val _currentGameConfig = MutableStateFlow(GameConfig())
+ val currentGameConfig = _currentGameConfig.asStateFlow()
+
+ // State for configuration progress
+ private val _configurationProgress = MutableStateFlow(null)
+ val configurationProgress: StateFlow = _configurationProgress.asStateFlow()
+
+ // State for individual device configuration status
+ private val _deviceConfigStatus = MutableStateFlow