Functionality restored, build successful
|
@ -2,13 +2,25 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/konfigurator_icon"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:roundIcon="@mipmap/konfigurator_icon_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Konfigurator"
|
||||
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
|
||||
|
||||
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<String>,
|
||||
onRequestPermissions: () -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "The following permissions are required:",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
deniedPermissions.forEach { permission ->
|
||||
Text(
|
||||
text = "• ${permission.split(".").last()}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d(TAG, "Permission request button clicked")
|
||||
onRequestPermissions()
|
||||
},
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
Text("Grant Permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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<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>
|