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