Functionality restored, build successful
|
@ -1,5 +1,7 @@
|
||||||
# Android Konfigurator
|
# Android Konfigurator
|
||||||
|
|
||||||
|
<img src="konfigurator.png" width=300px/>
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This software is used for configuring and initiating KTag games.
|
This software is used for configuring and initiating KTag games.
|
||||||
|
|
|
@ -2,13 +2,25 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.bluetooth_le"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/konfigurator_icon"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/konfigurator_icon_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Konfigurator"
|
android:theme="@style/Theme.Konfigurator"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
|
12
app/src/main/java/club/clubk/ktag/konfigurator/AppState.kt
Normal file
|
@ -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()
|
||||||
|
}
|
322
app/src/main/java/club/clubk/ktag/konfigurator/BleManager.kt
Normal file
|
@ -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<Boolean>()
|
||||||
|
var tempAdvertiseCallback: AdvertiseCallback? = null
|
||||||
|
var tempScanCallback: ScanCallback? = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
tempAdvertiseCallback = object : AdvertiseCallback() {
|
||||||
|
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
|
||||||
|
super.onStartSuccess(settingsInEffect)
|
||||||
|
Log.d(TAG_BLE_AD,"Config advertisement started successfully.")
|
||||||
|
// Only start scanning AFTER advertisement has successfully started
|
||||||
|
tempScanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
Log.d(TAG_BLE_SCAN, ">>> ScanCallback.onScanResult ENTERED")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package club.clubk.ktag.konfigurator
|
||||||
|
|
||||||
|
data class ConfigurationProgress(
|
||||||
|
val configuredCount: Int,
|
||||||
|
val totalCount: Int,
|
||||||
|
val isComplete: Boolean
|
||||||
|
)
|
24
app/src/main/java/club/clubk/ktag/konfigurator/Device.kt
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package club.clubk.ktag.konfigurator
|
||||||
|
|
||||||
|
sealed class DeviceState {
|
||||||
|
object Configurable : DeviceState()
|
||||||
|
object Ready : DeviceState()
|
||||||
|
object Playing : DeviceState()
|
||||||
|
object WrapUp : DeviceState()
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
365
app/src/main/java/club/clubk/ktag/konfigurator/KTagPacket.kt
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +1,655 @@
|
||||||
package club.clubk.ktag.konfigurator
|
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.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.runtime.Composable
|
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.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 club.clubk.ktag.konfigurator.ui.theme.KonfiguratorTheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val TAG = "Konfigurator"
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
checkAndRequestPermissions()
|
||||||
|
// enableEdgeToEdge()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mainScreen() {
|
||||||
setContent {
|
setContent {
|
||||||
KonfiguratorTheme {
|
KonfiguratorTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
StateMachine(StateMachineViewModel(applicationContext))
|
||||||
Greeting(
|
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
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(
|
||||||
text = "Hello $name!",
|
text = "The following permissions are required:",
|
||||||
modifier = modifier
|
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
|
@Composable
|
||||||
fun GreetingPreview() {
|
fun TitleScreen(stateMachine: StateMachineViewModel) {
|
||||||
KonfiguratorTheme {
|
Column(
|
||||||
Greeting("Android")
|
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<Device>, 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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<ParametersPacket>,
|
||||||
|
onDeviceConfigured: suspend (deviceAddress: String, success: Boolean) -> Unit,
|
||||||
|
onProgressUpdate: suspend (configuredCount: Int, totalCount: Int) -> Unit
|
||||||
|
): Map<String, Boolean> {
|
||||||
|
val results = mutableMapOf<String, Boolean>()
|
||||||
|
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<ParametersPacket>) {
|
||||||
|
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
|
||||||
|
}
|
10
app/src/main/java/club/clubk/ktag/konfigurator/Player.kt
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>(AppState.TitleScreen)
|
||||||
|
val appState: StateFlow<AppState> = _appState.asStateFlow()
|
||||||
|
|
||||||
|
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||||
|
val devices: StateFlow<List<Device>> = _devices.asStateFlow()
|
||||||
|
|
||||||
|
private val _allDevicesReady = MutableStateFlow<Boolean>(false)
|
||||||
|
val allDevicesReady = _allDevicesReady.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentGameConfig = MutableStateFlow(GameConfig())
|
||||||
|
val currentGameConfig = _currentGameConfig.asStateFlow()
|
||||||
|
|
||||||
|
// State for configuration progress
|
||||||
|
private val _configurationProgress = MutableStateFlow<ConfigurationProgress?>(null)
|
||||||
|
val configurationProgress: StateFlow<ConfigurationProgress?> = _configurationProgress.asStateFlow()
|
||||||
|
|
||||||
|
// State for individual device configuration status
|
||||||
|
private val _deviceConfigStatus = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||||
|
val deviceConfigStatus: StateFlow<Map<String, Boolean>> = _deviceConfigStatus.asStateFlow()
|
||||||
|
|
||||||
|
private val bleManager = BleManager.getInstance(context)
|
||||||
|
private val instigatePacketGenerator = InstigatePacketGenerator()
|
||||||
|
private val eventPacketGenerator = EventPacketGenerator()
|
||||||
|
private val parameterPacketGenerator = ParametersPacketGenerator()
|
||||||
|
private val multiDeviceConfigurator = MultiDeviceConfigurator(bleManager)
|
||||||
|
|
||||||
|
private val soundManager = SoundManager(context)
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
soundManager.release()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private val scanCallbackDefault = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
|
||||||
|
if (data.size >= 4 &&
|
||||||
|
data[0] == 0x4B.toByte() && // K
|
||||||
|
data[1] == 0x54.toByte() && // T
|
||||||
|
data[2] == 0x61.toByte() && // a
|
||||||
|
data[3] == 0x67.toByte() // g
|
||||||
|
) {
|
||||||
|
val packet = byteArrayToKTagPacket(data)
|
||||||
|
val scannedDevice = Device(address = result.device.address)
|
||||||
|
when (packet) {
|
||||||
|
is HelloPacket -> {
|
||||||
|
// Log.d(TAG_BLE_SCAN, "HelloPacket scanned")
|
||||||
|
scannedDevice.name = packet.deviceName
|
||||||
|
scannedDevice.deviceType = packet.deviceType
|
||||||
|
scannedDevice.team = packet.teamId
|
||||||
|
scannedDevice.deviceState = DeviceState.Configurable
|
||||||
|
addOrRefreshDevice(scannedDevice)
|
||||||
|
}
|
||||||
|
is ConsolePacket -> {
|
||||||
|
// Log.d(TAG_BLE_SCAN, "ConsolePacket scanned")
|
||||||
|
}
|
||||||
|
is EventPacket -> {
|
||||||
|
// Log.d(TAG_BLE_SCAN, "EventPacket scanned")
|
||||||
|
}
|
||||||
|
is InstigateGamePacket -> {
|
||||||
|
// Log.d(TAG_BLE_SCAN, "InstigateGamePacket scanned")
|
||||||
|
}
|
||||||
|
is ParametersPacket -> {
|
||||||
|
// Log.d(TAG_BLE_SCAN, "ParametersPacket scanned")
|
||||||
|
}
|
||||||
|
is StatusPacket -> {
|
||||||
|
when (packet.systemKState) {
|
||||||
|
3 -> scannedDevice.deviceState = DeviceState.Ready
|
||||||
|
7 -> scannedDevice.deviceState = DeviceState.Playing
|
||||||
|
9 -> scannedDevice.deviceState = DeviceState.WrapUp
|
||||||
|
}
|
||||||
|
addOrRefreshDevice(scannedDevice)
|
||||||
|
}
|
||||||
|
is TagPacket -> {
|
||||||
|
// Log.d(TAG_BLE_SCAN, "TagPacket scanned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
val errorMessage = when (errorCode) {
|
||||||
|
SCAN_FAILED_ALREADY_STARTED -> "Already started"
|
||||||
|
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
|
||||||
|
SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
|
||||||
|
SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
|
||||||
|
else -> "Unknown error $errorCode"
|
||||||
|
}
|
||||||
|
Log.e(TAG_BLE_SCAN, "BLE Scan failed: $errorMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val advertisingCallbackDefault = object : AdvertiseCallback() {
|
||||||
|
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||||
|
super.onStartSuccess(settingsInEffect)
|
||||||
|
Log.i(
|
||||||
|
TAG_BLE_AD, "Advertisement started successfully with settings: " +
|
||||||
|
"Mode=${settingsInEffect.mode}, TxPower=${settingsInEffect.txPowerLevel}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartFailure(errorCode: Int) {
|
||||||
|
super.onStartFailure(errorCode)
|
||||||
|
val errorMessage = when (errorCode) {
|
||||||
|
ADVERTISE_FAILED_ALREADY_STARTED -> "Already started"
|
||||||
|
ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large"
|
||||||
|
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
|
||||||
|
ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error"
|
||||||
|
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
|
||||||
|
else -> "Unknown error $errorCode"
|
||||||
|
}
|
||||||
|
Log.e(TAG_BLE_AD, "Failed to start advertising: $errorMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addOrRefreshDevice(newDevice: Device) {
|
||||||
|
val currentDevices = _devices.value.toMutableList()
|
||||||
|
val index = currentDevices.indexOfFirst { it.address == newDevice.address }
|
||||||
|
if (index == -1) {
|
||||||
|
currentDevices += newDevice
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var oldDevice = currentDevices[index]
|
||||||
|
newDevice.name = oldDevice.name
|
||||||
|
newDevice.deviceType = oldDevice.deviceType ?: newDevice.deviceType
|
||||||
|
newDevice.team = oldDevice.team ?: newDevice.team
|
||||||
|
newDevice.playerID = oldDevice.playerID ?: newDevice.playerID
|
||||||
|
newDevice.deviceState = newDevice.deviceState ?: oldDevice.deviceState
|
||||||
|
currentDevices[index] = newDevice
|
||||||
|
}
|
||||||
|
_devices.value = currentDevices
|
||||||
|
Log.d("AddRefresh", _devices.value.toString())
|
||||||
|
_allDevicesReady.value = allDevicesReady()
|
||||||
|
Log.d("AddRefresh", _allDevicesReady.value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDevice(newDevice: Device) {
|
||||||
|
val currentDevices = _devices.value.toMutableList()
|
||||||
|
val index = currentDevices.indexOfFirst { it.address == newDevice.address }
|
||||||
|
if (index == -1) { return }
|
||||||
|
var oldDevice = currentDevices[index]
|
||||||
|
newDevice.deviceType = newDevice.deviceType ?: oldDevice.deviceType
|
||||||
|
newDevice.team = newDevice.team ?: oldDevice.team
|
||||||
|
newDevice.playerID = newDevice.playerID ?: oldDevice.playerID
|
||||||
|
currentDevices[index] = newDevice
|
||||||
|
_devices.value = currentDevices
|
||||||
|
_allDevicesReady.value = allDevicesReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDeviceTeam(deviceAddress: String, newTeam: Int) {
|
||||||
|
_devices.update { currentList ->
|
||||||
|
currentList.map { device ->
|
||||||
|
if (device.address == deviceAddress) {
|
||||||
|
device.copy(team = newTeam) // Creates a new Device instance
|
||||||
|
} else {
|
||||||
|
device
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_allDevicesReady.value = allDevicesReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cycleDeviceTeam(device: Device) {
|
||||||
|
Log.d("STATEMACHINE", "cycling device team")
|
||||||
|
var newTeam = device.team ?: -1
|
||||||
|
newTeam++
|
||||||
|
if (newTeam > 2) {
|
||||||
|
newTeam = 0
|
||||||
|
}
|
||||||
|
updateDeviceTeam(device.address, newTeam)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allDevicesReady(): Boolean {
|
||||||
|
if (_devices.value.isEmpty()) { return false }
|
||||||
|
for (device in _devices.value) {
|
||||||
|
if (device.deviceState != DeviceState.Ready) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readyAllDevices() {
|
||||||
|
val eventPacket = eventPacketGenerator.generatePacket(targetAddress = "FF:FF:FF:FF:FF:FF", eventId = 2)
|
||||||
|
bleManager.startAdvertising(kTagPacketToByteArray(eventPacket), advertisingCallbackDefault)
|
||||||
|
bleManager.startScanning(scanCallbackDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGameConfig(newGameConfig: GameConfig) {
|
||||||
|
_currentGameConfig.value = newGameConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates the configuration of all devices using the MultiDeviceConfigurator.
|
||||||
|
*/
|
||||||
|
fun configureDevices() {
|
||||||
|
val currentDevicesToConfigure = _devices.value.filter {
|
||||||
|
// Refine this filter as needed. For example:
|
||||||
|
// it.deviceState == DeviceState.Configurable || it.needsReconfiguration
|
||||||
|
true // For now, assuming all devices in the list should be configured
|
||||||
|
}
|
||||||
|
val gameCfg = _currentGameConfig.value
|
||||||
|
|
||||||
|
if (currentDevicesToConfigure.isEmpty()) {
|
||||||
|
Log.i(TAG_STATE_MACHINE, "No devices eligible for configuration.")
|
||||||
|
_configurationProgress.value = ConfigurationProgress(0, 0, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Prepare the list of ParametersPacket (this part is similar to what you had,
|
||||||
|
// but now it collects all packets for all devices first)
|
||||||
|
val deviceConfigPackets = currentDevicesToConfigure.mapNotNull { device ->
|
||||||
|
val teamId = device.team ?: 0 // Default to team 0 if not set
|
||||||
|
|
||||||
|
// Create your ParametersPacket.
|
||||||
|
// If a device can have multiple configuration packets, generate them all here
|
||||||
|
// and return a List<ParametersPacket> from this lambda, then use flatMapNotNull.
|
||||||
|
// For now, assuming one packet per device for simplicity.
|
||||||
|
parameterPacketGenerator.generatePacket(
|
||||||
|
targetAddress = device.address,
|
||||||
|
subtype = 2, // Request Parameter Change
|
||||||
|
key1 = 1, value1 = teamId, // Key 1 is Team ID
|
||||||
|
key2 = 4, value2 = gameCfg.maxHealth // Key 2 is Max Health
|
||||||
|
)
|
||||||
|
// If a device for some reason can't be configured (e.g. missing address),
|
||||||
|
// you could return null here, and mapNotNull will filter it out.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceConfigPackets.isEmpty() && currentDevicesToConfigure.isNotEmpty()) {
|
||||||
|
Log.w(TAG_STATE_MACHINE, "Could not generate config packets for any selected device.")
|
||||||
|
_configurationProgress.value = ConfigurationProgress(0, currentDevicesToConfigure.size, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG_STATE_MACHINE, "Starting configuration for ${deviceConfigPackets.size} devices.")
|
||||||
|
_deviceConfigStatus.value = emptyMap() // Reset status for the new session
|
||||||
|
_configurationProgress.value = ConfigurationProgress(0, deviceConfigPackets.size, false)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Optional: Stop general scanning if it might interfere with the
|
||||||
|
// specific scan/advertise sequence within bleManager.configureDevice()
|
||||||
|
// Log.d(TAG_STATE_MACHINE, "Stopping general BLE scan for configuration.")
|
||||||
|
// bleManager.stopScanning()
|
||||||
|
|
||||||
|
// 2. THIS IS THE REPLACEMENT: Call your MultiDeviceConfigurator
|
||||||
|
val results = multiDeviceConfigurator.configureMultipleDevices(
|
||||||
|
deviceConfigs = deviceConfigPackets,
|
||||||
|
onDeviceConfigured = { deviceAddress, success ->
|
||||||
|
// This callback is invoked by your MultiDeviceConfigurator for each device
|
||||||
|
Log.i(TAG_STATE_MACHINE, "Device $deviceAddress configured (from callback): $success")
|
||||||
|
_deviceConfigStatus.update { currentStatus ->
|
||||||
|
currentStatus + (deviceAddress to success)
|
||||||
|
}
|
||||||
|
// You could also update the Device object in _devices if needed:
|
||||||
|
// _devices.update { list ->
|
||||||
|
// list.map { if (it.address == deviceAddress) it.copy(isConfigured = success) else it }
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
onProgressUpdate = { configuredCount, totalCount ->
|
||||||
|
// This callback is invoked by your MultiDeviceConfigurator on progress
|
||||||
|
Log.i(TAG_STATE_MACHINE, "Configuration progress (from callback): $configuredCount/$totalCount")
|
||||||
|
_configurationProgress.value = ConfigurationProgress(configuredCount, totalCount, configuredCount == totalCount)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Process overall results (optional, as callbacks already update state)
|
||||||
|
Log.i(TAG_STATE_MACHINE, "All device configurations attempted by MultiDeviceConfigurator. Final Results map: $results")
|
||||||
|
val allSucceeded = results.values.all { it }
|
||||||
|
if (allSucceeded) {
|
||||||
|
Log.i(TAG_STATE_MACHINE, "All devices configured successfully (overall check).")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG_STATE_MACHINE, "One or more devices failed to configure (overall check).")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Restart general scanning if it was stopped earlier
|
||||||
|
// Log.d(TAG_STATE_MACHINE, "Restarting general BLE scan after configuration.")
|
||||||
|
// bleManager.startScanning(scanCallbackDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextState() {
|
||||||
|
soundManager.playDoubleChirp()
|
||||||
|
when (_appState.value) {
|
||||||
|
is AppState.TitleScreen -> {
|
||||||
|
_appState.value = AppState.GameSettings
|
||||||
|
}
|
||||||
|
is AppState.GameSettings -> {
|
||||||
|
_appState.value = AppState.TeamSettings
|
||||||
|
bleManager.startScanning(scanCallbackDefault)
|
||||||
|
}
|
||||||
|
is AppState.TeamSettings -> {
|
||||||
|
_appState.value = AppState.PregameTimer
|
||||||
|
val instigatePacket = instigatePacketGenerator.generatePacket(
|
||||||
|
_currentGameConfig.value.gameLength,
|
||||||
|
_currentGameConfig.value.pregameLength
|
||||||
|
)
|
||||||
|
bleManager.stopScanning()
|
||||||
|
bleManager.startAdvertising(kTagPacketToByteArray(instigatePacket), advertisingCallbackDefault)
|
||||||
|
}
|
||||||
|
is AppState.PregameTimer -> {
|
||||||
|
_appState.value = AppState.Countdown
|
||||||
|
bleManager.stopAdvertising()
|
||||||
|
}
|
||||||
|
is AppState.Countdown -> {
|
||||||
|
_appState.value = AppState.GameTimer
|
||||||
|
}
|
||||||
|
is AppState.GameTimer -> {
|
||||||
|
_appState.value = AppState.GameOver
|
||||||
|
val gameOverPacket = eventPacketGenerator.generatePacket(targetAddress = "FF:FF:FF:FF:FF:FF", eventId = 4)
|
||||||
|
bleManager.startAdvertising(kTagPacketToByteArray(gameOverPacket), advertisingCallbackDefault)
|
||||||
|
}
|
||||||
|
AppState.GameOver -> {
|
||||||
|
_appState.value = AppState.GameSettings
|
||||||
|
val wrapupCompletePacket = eventPacketGenerator.generatePacket(targetAddress = "FF:FF:FF:FF:FF:FF", eventId = 3, eventData = 2)
|
||||||
|
bleManager.startAdvertising(kTagPacketToByteArray(wrapupCompletePacket), advertisingCallbackDefault)
|
||||||
|
}
|
||||||
|
AppState.WrapUp -> {
|
||||||
|
_appState.value = AppState.GameSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
BIN
app/src/main/konfigurator_icon-playstore.png
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
app/src/main/res/drawable/konfigurator.png
Normal file
After Width: | Height: | Size: 36 KiB |
5
app/src/main/res/mipmap-anydpi-v26/konfigurator_icon.xml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/konfigurator_icon_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/konfigurator_icon_foreground"/>
|
||||||
|
</adaptive-icon>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/konfigurator_icon_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/konfigurator_icon_foreground"/>
|
||||||
|
</adaptive-icon>
|
BIN
app/src/main/res/mipmap-hdpi/konfigurator_icon.webp
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-hdpi/konfigurator_icon_foreground.webp
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/res/mipmap-hdpi/konfigurator_icon_round.webp
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/konfigurator_icon.webp
Normal file
After Width: | Height: | Size: 980 B |
BIN
app/src/main/res/mipmap-mdpi/konfigurator_icon_foreground.webp
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/konfigurator_icon_round.webp
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/konfigurator_icon.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/konfigurator_icon_foreground.webp
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/konfigurator_icon_round.webp
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/konfigurator_icon.webp
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/konfigurator_icon_foreground.webp
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/konfigurator_icon_round.webp
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/konfigurator_icon.webp
Normal file
After Width: | Height: | Size: 4.4 KiB |
After Width: | Height: | Size: 7.6 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/konfigurator_icon_round.webp
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/main/res/raw/double_chirp.wav
Normal file
4
app/src/main/res/values/konfigurator_icon_background.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="konfigurator_icon_background">#000000</color>
|
||||||
|
</resources>
|
BIN
konfigurator.png
Normal file
After Width: | Height: | Size: 36 KiB |