Initial public release.
264
subapp-konfigurator/README.md
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
# KTag Konfigurator Subapp
|
||||
|
||||
A Jetpack Compose Android application for configuring KTag laser tag devices via BLE and coordinating game sessions.
|
||||
|
||||
## Overview
|
||||
|
||||
The Konfigurator manages the full lifecycle of a KTag game session: configuring game parameters, discovering and assigning devices to teams, broadcasting game start/stop events, and running game timers. It communicates with KTag devices over BLE using the KTag 27-byte packet protocol.
|
||||
|
||||
## Architecture
|
||||
|
||||
The app follows the **MVVM (Model-View-ViewModel)** pattern with a state machine driving screen transitions.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ KonfiguratorActivity │
|
||||
│ (Compose Host + Permissions) │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────┐
|
||||
│ StateMachineViewModel │
|
||||
│ • State machine (6 screens) │
|
||||
│ • BLE scanning & advertising │
|
||||
│ • Multi-device configuration │
|
||||
│ • Game config management │
|
||||
└──────┬──────────────┬───────────────────────────────────┘
|
||||
│ │
|
||||
┌──────▼──────┐ ┌─────▼──────────────────────────────────┐
|
||||
│ BleManager │ │ MultiDeviceConfigurator │
|
||||
│ (Singleton) │ │ (Sequential BLE config with mutex) │
|
||||
└─────────────┘ └────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/main/java/club/clubk/ktag/apps/konfigurator/
|
||||
├── KonfiguratorActivity.kt # Compose activity with BLE permissions
|
||||
├── StateMachineViewModel.kt # State machine, BLE orchestration
|
||||
├── BleManager.kt # BLE scan/advertise singleton
|
||||
├── MultiDeviceConfigurator.kt # Sequential multi-device configuration
|
||||
├── KTagPacket.kt # Packet types, parsing, generators
|
||||
├── Device.kt # Device data class
|
||||
├── DeviceState.kt # Sealed class: Configurable/Ready/Playing/WrapUp
|
||||
├── AppState.kt # Sealed class: 6 screen states
|
||||
├── GameConfig.kt # Game parameters data class
|
||||
├── ConfigurationProgress.kt # Configuration progress tracking
|
||||
├── Player.kt # Player data class
|
||||
├── SoundManager.kt # Audio feedback (SoundPool)
|
||||
├── KonfiguratorSubApp.kt # Subapp registration
|
||||
├── KonfiguratorInitializer.kt # androidx.startup initializer
|
||||
└── ui/theme/
|
||||
├── Theme.kt # Material3 dynamic theming
|
||||
├── Color.kt # Color palette
|
||||
└── Type.kt # Typography
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
┌───────────────┐ │
|
||||
│ GameSettings │ Configure duration, health, rounds │
|
||||
└───────┬───────┘ │
|
||||
│ → start BLE scanning │
|
||||
▼ │
|
||||
┌───────────────┐ │
|
||||
│ TeamSettings │ Discover devices, assign teams, apply config │
|
||||
└───────┬───────┘ │
|
||||
│ [all devices ready] │
|
||||
│ → stop scanning │
|
||||
│ → advertise InstigatingGame packet │
|
||||
▼ │
|
||||
┌───────────────┐ │
|
||||
│ PregameTimer │ Countdown before game │
|
||||
└───────┬───────┘ │
|
||||
│ [timer expires] │
|
||||
│ → stop advertising │
|
||||
▼ │
|
||||
┌───────────────┐ │
|
||||
│ Countdown │ Countdown lights sequence (5+1 s) │
|
||||
└───────┬───────┘ │
|
||||
│ [lights out] │
|
||||
▼ │
|
||||
┌───────────────┐ │
|
||||
│ GameTimer │ Active game countdown │
|
||||
└───────┬───────┘ │
|
||||
│ [timer expires / End Game] │
|
||||
│ → advertise EVENT_GAME_OVER │
|
||||
▼ │
|
||||
┌───────────────┐ │
|
||||
│ WrapUp │ → advertise EVENT_WRAPUP_COMPLETE ──────────────────┘
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
| State | Description |
|
||||
|----------------|----------------------------------------------------------------------------|
|
||||
| `GameSettings` | Configure game duration, health, and rounds |
|
||||
| `TeamSettings` | Scan for BLE devices, assign teams (Purple/Red/Blue), apply config |
|
||||
| `PregameTimer` | Pregame countdown; InstigatingGame packet keeps devices in sync |
|
||||
| `Countdown` | Countdown lights-out sequence (6 s) |
|
||||
| `GameTimer` | Active game countdown with pie-chart timer; early-end available |
|
||||
| `WrapUp` | Game complete; broadcasts EVENT_WRAPUP_COMPLETE, returns to GameSettings |
|
||||
|
||||
## Key Components
|
||||
|
||||
### StateMachineViewModel
|
||||
|
||||
Manages game state and BLE operations:
|
||||
|
||||
- **State Machine**: Transitions between 6 screen states, plays audio on each transition
|
||||
- **Device Discovery**: BLE scanning with KTag manufacturer data filter (0xFFFF)
|
||||
- **Team Assignment**: Cycle devices through teams (0=Purple, 1=Red, 2=Blue)
|
||||
- **Configuration**: Sends Parameters packets to assign team and health to each device
|
||||
- **Game Control**: Broadcasts Instigate Game, Event (ready/game over/wrapup) packets
|
||||
|
||||
### BleManager
|
||||
|
||||
Singleton managing BLE operations:
|
||||
|
||||
- **Advertising**: Broadcasts KTag packets as manufacturer-specific data
|
||||
- **Scanning**: Filters for KTag magic bytes with scan holdoff (5s between scans)
|
||||
- **Device Configuration**: Advertise config packet, scan for ACK, with 5s timeout
|
||||
- **Thread Safety**: Coroutine-based with managed scan start jobs
|
||||
|
||||
### MultiDeviceConfigurator
|
||||
|
||||
Sequential device configuration:
|
||||
|
||||
- **Mutex-Protected**: Ensures one BLE config operation at a time
|
||||
- **Progress Callbacks**: Reports per-device success/failure and overall progress
|
||||
- **Error Handling**: Catches exceptions per-device, continues with remaining devices
|
||||
|
||||
### KTagPacket
|
||||
|
||||
Packet system with sealed class hierarchy:
|
||||
|
||||
- 7 packet types: InstigateGame, Event, Tag, Console, Status, Parameters, Hello
|
||||
- `byteArrayToKTagPacket()`: Deserializes raw bytes to typed packets
|
||||
- `kTagPacketToByteArray()`: Serializes packets to BLE advertisement bytes
|
||||
- Packet generators with auto-incrementing event counters
|
||||
|
||||
## Game Configuration
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---------------------------|---------|---------------------------------|
|
||||
| `gameDurationMin` | 10 | Game duration in minutes |
|
||||
| `timeUntilCountdownS` | 30 | Pregame countdown in seconds |
|
||||
| `numRounds` | 2 | Number of rounds |
|
||||
| `maxHealth` | 10 | Maximum player health |
|
||||
| `specialWeaponsOnReentry` | 1 | Special weapons on game reentry |
|
||||
|
||||
## How to Add a New Configurable Parameter to a Device
|
||||
|
||||
Each configurable parameter follows the same "set then confirm" pattern: the app holds a *desired* value and a *broadcasted* (device-confirmed) value. The accent bar and checkmark on a device card turn green only when every parameter's desired value matches its broadcasted value.
|
||||
|
||||
The steps below use `fooBar` / `broadcastedFooBar` as the example parameter name.
|
||||
|
||||
### 1. Add the protocol key — `core/.../Packet.java`
|
||||
|
||||
Parameter keys are defined in the [KTag BLE Protocol Specification](https://ktag.clubk.club/Technology/BLE/). Check there first — the key you need may already exist as a `PARAMETER_KEY_*` constant in `Packet.java`. If it does not, add it:
|
||||
|
||||
```java
|
||||
public static final int PARAMETER_KEY_FOO_BAR = <key_id_from_spec>;
|
||||
```
|
||||
|
||||
Each Parameters BLE packet carries two key-value slots. If both slots in the last packet are already occupied, the new parameter needs its own packet (see step 6).
|
||||
|
||||
### 2. Add fields to the device model — `Device.kt`
|
||||
|
||||
```kotlin
|
||||
var fooBar: Int? = null,
|
||||
var broadcastedFooBar: Int? = null
|
||||
```
|
||||
|
||||
`fooBar` is the value the operator wants to send. `broadcastedFooBar` is the value last acknowledged by the device.
|
||||
|
||||
### 3. Register the parameter for match-checking — `Device.kt`
|
||||
|
||||
Add one line to `allSettingsMatch()`:
|
||||
|
||||
```kotlin
|
||||
fun allSettingsMatch(): Boolean =
|
||||
paramMatches(team, broadcastedTeam) &&
|
||||
paramMatches(maxHealth, broadcastedMaxHealth) &&
|
||||
paramMatches(specialWeaponsOnReentry, broadcastedSpecialWeaponsOnReentry) &&
|
||||
paramMatches(fooBar, broadcastedFooBar) // ← add this
|
||||
```
|
||||
|
||||
### 4. Register the parameter for configure confirmation — `StateMachineViewModel.kt`
|
||||
|
||||
In `configureDevices()`, two `confirmOnSuccess` lambdas are built in parallel with the packet list. Each lambda is applied to the device immediately when its specific packet is ACK'd. Add `broadcastedFooBar` to whichever lambda corresponds to the packet that carries `PARAMETER_KEY_FOO_BAR`:
|
||||
|
||||
```kotlin
|
||||
confirmOnSuccess += { d -> d.copy(
|
||||
broadcastedTeam = d.team ?: d.broadcastedTeam,
|
||||
broadcastedMaxHealth = d.maxHealth ?: d.broadcastedMaxHealth,
|
||||
broadcastedFooBar = d.fooBar ?: d.broadcastedFooBar // ← add this
|
||||
)}
|
||||
```
|
||||
|
||||
This ensures a failed packet never blocks confirmation of packets that succeeded.
|
||||
|
||||
### 5. Preserve both fields when refreshing a device — `StateMachineViewModel.kt`
|
||||
|
||||
`addOrRefreshDevice()` merges incoming scan data with the existing device record. Add two lines following the same pattern as the other parameters:
|
||||
|
||||
```kotlin
|
||||
newDevice.fooBar = oldDevice.fooBar ?: newDevice.fooBar
|
||||
newDevice.broadcastedFooBar = newDevice.broadcastedFooBar ?: oldDevice.broadcastedFooBar
|
||||
```
|
||||
|
||||
### 6. Initialize and send the parameter — `StateMachineViewModel.kt`
|
||||
|
||||
**Initialize** `fooBar` when a Hello packet is received (typically from game config defaults, same as `maxHealth`):
|
||||
|
||||
```kotlin
|
||||
scannedDevice.fooBar = _currentGameConfig.value.fooBar
|
||||
```
|
||||
|
||||
**Send** it during configure. In `configureDevices()`, the `flatMap` builds one `Pair<String, Packet.Parameters>` per BLE packet. Each Parameters packet has two key-value slots. Either fill an empty slot in an existing packet or add a new one:
|
||||
|
||||
```kotlin
|
||||
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
|
||||
Packet.PARAMETER_KEY_FOO_BAR, device.fooBar ?: gameCfg.fooBar,
|
||||
Packet.PARAMETER_KEY_NONE, 0))
|
||||
```
|
||||
|
||||
If the device also broadcasts `fooBar` in its Status packets, parse it in the Status handler and set `scannedDevice.broadcastedFooBar` there (same as `broadcastedTeam`). This provides an ongoing truth-check from the device in addition to the immediate confirmation from the ACK packet.
|
||||
|
||||
### 7. Add an update function — `StateMachineViewModel.kt`
|
||||
|
||||
```kotlin
|
||||
fun updateDeviceFooBar(deviceAddress: String, value: Int) =
|
||||
updateDeviceField(deviceAddress) { it.copy(fooBar = value) }
|
||||
```
|
||||
|
||||
`updateDeviceField` handles the `_devices` update and sets `_pendingApply = true` automatically.
|
||||
|
||||
### 8. Add a UI field to the device detail dialog — `KonfiguratorActivity.kt`
|
||||
|
||||
In `DeviceDetailDialog`, add a state variable and an `IntegerTextField`:
|
||||
|
||||
```kotlin
|
||||
var fooBarText by remember { mutableStateOf(device.fooBar?.toString() ?: "") }
|
||||
var fooBarError by remember { mutableStateOf(false) }
|
||||
|
||||
IntegerTextField(
|
||||
value = fooBarText,
|
||||
onValueChange = { fooBarText = it; fooBarError = false },
|
||||
label = "Foo Bar",
|
||||
isError = fooBarError
|
||||
)
|
||||
```
|
||||
|
||||
Update the `onSave` lambda signature to include the new value, validate it in the Save button, and call `stateMachine.updateDeviceFooBar(device.address, fooBar)` alongside the other update calls.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Jetpack Compose (Material3)
|
||||
- ViewModel + Compose integration
|
||||
- BLE (BluetoothLeScanner, BluetoothLeAdvertiser)
|
||||
- SoundPool (audio feedback)
|
||||
36
subapp-konfigurator/build.gradle.kts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "club.clubk.ktag.apps.konfigurator"
|
||||
compileSdk = 36
|
||||
defaultConfig { minSdk = 24 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":shared-services"))
|
||||
implementation("androidx.preference:preference:1.2.1")
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
28
subapp-konfigurator/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<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>
|
||||
<activity
|
||||
android:name=".KonfiguratorActivity"
|
||||
android:exported="false"
|
||||
android:label="Konfigurator" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="club.clubk.ktag.apps.konfigurator.KonfiguratorInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
sealed class AppState {
|
||||
object GameSettings : AppState()
|
||||
object TeamSettings : AppState()
|
||||
object PregameTimer : AppState()
|
||||
object Countdown : AppState()
|
||||
object GameTimer : AppState()
|
||||
object WrapUp : AppState()
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
package club.clubk.ktag.apps.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 club.clubk.ktag.apps.core.ble.Packet
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun configureDevice(configPacket: Packet.Parameters, targetAddress: String): 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")
|
||||
if (Packet.IsKTagParametersPacket(result)) {
|
||||
val scannedParams = Packet.Parameters(result)
|
||||
Log.d(TAG_BLE_SCAN, "Parameter packet scanned, checking for parameter match...")
|
||||
if (result.device.address == targetAddress
|
||||
&& scannedParams.subtype == Packet.PARAMETER_SUBTYPE_ACKNOWLEDGE_CHANGE
|
||||
&& scannedParams.key1 == configPacket.key1
|
||||
&& scannedParams.value1 == configPacket.value1
|
||||
&& scannedParams.key2 == configPacket.key2
|
||||
&& scannedParams.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop any in-progress regular scan, then start the ACK scan
|
||||
// directly — bypassing startScanning's holdoff, which is the same
|
||||
// duration as the configure timeout and would always cause a miss.
|
||||
scanStartJob?.cancel()
|
||||
internalStopScan()
|
||||
val ackFilter = ScanFilter.Builder()
|
||||
.setManufacturerData(0xFFFF, byteArrayOf(0x4B, 0x54, 0x61, 0x67))
|
||||
.build()
|
||||
val ackSettings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
||||
try {
|
||||
bluetoothLeScanner?.startScan(listOf(ackFilter), ackSettings, tempScanCallback!!)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG_BLE_SCAN, "Permission missing for ACK scan", e)
|
||||
if (!advertisementDetected.isCompleted) advertisementDetected.complete(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 $targetAddress...")
|
||||
this@BleManager.startAdvertising(configPacket.GetBytes(),
|
||||
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 $targetAddress")
|
||||
false
|
||||
} finally {
|
||||
Log.d(TAG_BLE_SCAN, "Cleaning up configureDevice resources for $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() }
|
||||
// Stop the ACK scan directly so we don't stamp a new holdoff timestamp
|
||||
// between consecutive configure packets.
|
||||
tempScanCallback?.let {
|
||||
try {
|
||||
bluetoothLeScanner?.stopScan(it)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG_BLE_SCAN, "Permission missing for stopping ACK scan", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
data class ConfigurationProgress(
|
||||
val configuredCount: Int,
|
||||
val totalCount: Int,
|
||||
val isComplete: Boolean
|
||||
)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
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 broadcastedTeam: Int? = null,
|
||||
var playerID : Int? = null,
|
||||
var deviceState: DeviceState? = null,
|
||||
var systemkMajorVersion: Int? = null,
|
||||
var systemkMinorVersion: Int? = null,
|
||||
var maxHealth: Int? = null,
|
||||
var broadcastedMaxHealth: Int? = null,
|
||||
var specialWeaponsOnReentry: Int? = null,
|
||||
var broadcastedSpecialWeaponsOnReentry: Int? = null
|
||||
) {
|
||||
|
||||
fun deviceTypeName(): String {
|
||||
return Packet.getDeviceTypeName(deviceType ?: -1)
|
||||
}
|
||||
|
||||
/** True when every configured parameter has been acknowledged by the device. */
|
||||
fun allSettingsMatch(): Boolean =
|
||||
paramMatches(team, broadcastedTeam) &&
|
||||
paramMatches(maxHealth, broadcastedMaxHealth) &&
|
||||
paramMatches(specialWeaponsOnReentry, broadcastedSpecialWeaponsOnReentry)
|
||||
|
||||
fun isOutdatedSystemK(): Boolean {
|
||||
val major = systemkMajorVersion ?: return false
|
||||
val minor = systemkMinorVersion ?: return false
|
||||
return major < MinSystemKVersion.MAJOR || (major == MinSystemKVersion.MAJOR && minor < MinSystemKVersion.MINOR)
|
||||
}
|
||||
|
||||
fun SystemKVersionString(): String {
|
||||
val major = systemkMajorVersion ?: return "Unknown"
|
||||
val minor = systemkMinorVersion ?: return "Unknown"
|
||||
return "$major.${minor.toString().padStart(2, '0')}"
|
||||
}
|
||||
}
|
||||
|
||||
/** A device parameter matches when it has been acknowledged and equals the desired value. */
|
||||
private fun paramMatches(desired: Int?, broadcasted: Int?) = broadcasted != null && desired == broadcasted
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
sealed class DeviceState {
|
||||
object Configurable : DeviceState()
|
||||
object Ready : DeviceState()
|
||||
object Playing : DeviceState()
|
||||
object WrapUp : DeviceState()
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
data class GameConfig(
|
||||
var gameDurationMin: Int = 10,
|
||||
var timeUntilCountdownS: Int = 30,
|
||||
var numRounds: Int = 2,
|
||||
var maxHealth: Int = 10,
|
||||
var specialWeaponsOnReentry: Int = 1
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import club.clubk.ktag.apps.core.SubAppRegistry
|
||||
|
||||
class KonfiguratorInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
SubAppRegistry.register(KonfiguratorSubApp())
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.core.SubApp
|
||||
|
||||
class KonfiguratorSubApp : SubApp {
|
||||
override val id = "konfigurator"
|
||||
override val name = "Konfigurator"
|
||||
override val icon = R.drawable.konfigurator
|
||||
|
||||
override fun createIntent(context: Context): Intent {
|
||||
return Intent(context, KonfiguratorActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
import android.util.Log
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
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 Pair<String, Packet.Parameters> 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<Pair<String, Packet.Parameters>>,
|
||||
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 ((targetAddress, configPacket) in deviceConfigs) {
|
||||
var success = false
|
||||
try {
|
||||
bleOperationMutex.withLock {
|
||||
Log.i(
|
||||
"MultiDeviceConfigurator",
|
||||
"Attempting to configure $targetAddress"
|
||||
)
|
||||
success = bleManager.configureDevice(configPacket, targetAddress)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"MultiDeviceConfigurator",
|
||||
"Exception configuring $targetAddress",
|
||||
e
|
||||
)
|
||||
success = false
|
||||
}
|
||||
|
||||
results[targetAddress] = success
|
||||
configuredCount++
|
||||
onDeviceConfigured(targetAddress, success)
|
||||
onProgressUpdate(configuredCount, totalCount)
|
||||
|
||||
if (success) {
|
||||
Log.i(
|
||||
"MultiDeviceConfigurator",
|
||||
"Successfully configured $targetAddress"
|
||||
)
|
||||
} else {
|
||||
Log.w(
|
||||
"MultiDeviceConfigurator",
|
||||
"Failed to configure $targetAddress"
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package club.clubk.ktag.apps.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,60 @@
|
|||
package club.clubk.ktag.apps.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
|
||||
if (isLoaded) {
|
||||
playDoubleChirp()
|
||||
}
|
||||
}
|
||||
|
||||
// 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,389 @@
|
|||
package club.clubk.ktag.apps.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.preference.PreferenceManager
|
||||
import club.clubk.ktag.apps.sharedservices.DeviceInfo
|
||||
import club.clubk.ktag.apps.sharedservices.DeviceInfoMqttSync
|
||||
import club.clubk.ktag.apps.sharedservices.DeviceInfoRepository
|
||||
import club.clubk.ktag.apps.sharedservices.GamePreferenceKeys
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import club.clubk.ktag.apps.core.HexUtils
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
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.GameSettings)
|
||||
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 _pendingApply = MutableStateFlow(true)
|
||||
val pendingApply = _pendingApply.asStateFlow()
|
||||
|
||||
private val _isConfiguring = MutableStateFlow(false)
|
||||
val isConfiguring = _isConfiguring.asStateFlow()
|
||||
|
||||
private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val _currentGameConfig = MutableStateFlow(
|
||||
GameConfig(
|
||||
gameDurationMin = sharedPrefs.getString(GamePreferenceKeys.GAME_DURATION, GamePreferenceKeys.DEFAULT_GAME_DURATION)?.toIntOrNull()
|
||||
?: GamePreferenceKeys.DEFAULT_GAME_DURATION.toInt(),
|
||||
timeUntilCountdownS = sharedPrefs.getString(GamePreferenceKeys.TIME_UNTIL_COUNTDOWN, GamePreferenceKeys.DEFAULT_TIME_UNTIL_COUNTDOWN)?.toIntOrNull()
|
||||
?: GamePreferenceKeys.DEFAULT_TIME_UNTIL_COUNTDOWN.toInt()
|
||||
)
|
||||
)
|
||||
val currentGameConfig = _currentGameConfig.asStateFlow()
|
||||
val gameLengthMs: Long get() = _currentGameConfig.value.gameDurationMin.toLong() * 60 * 1000
|
||||
val pregameLengthMs: Long get() = _currentGameConfig.value.timeUntilCountdownS.toLong() * 1000
|
||||
|
||||
// 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 multiDeviceConfigurator = MultiDeviceConfigurator(bleManager)
|
||||
|
||||
private val soundManager = SoundManager(context)
|
||||
|
||||
private val deviceInfoRepository = DeviceInfoRepository.getInstance(context)
|
||||
private val mqttSync = DeviceInfoMqttSync(deviceInfoRepository)
|
||||
|
||||
init {
|
||||
mqttSync.connect()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
mqttSync.cleanup()
|
||||
soundManager.release()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private val scanCallbackDefault = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
if (Packet.IsKTagPacket(result)) {
|
||||
val scannedDevice = Device(address = result.device.address)
|
||||
when {
|
||||
Packet.IsKTagHelloPacket(result) -> {
|
||||
val hello = Packet.Hello(result)
|
||||
scannedDevice.name = hello.device_name
|
||||
scannedDevice.deviceType = hello.device_type
|
||||
scannedDevice.team = hello.team_ID.toInt()
|
||||
scannedDevice.broadcastedTeam = hello.team_ID.toInt()
|
||||
scannedDevice.deviceState = DeviceState.Configurable
|
||||
scannedDevice.systemkMajorVersion = hello.systemK_major_version
|
||||
scannedDevice.systemkMinorVersion = hello.systemK_minor_version
|
||||
scannedDevice.maxHealth = _currentGameConfig.value.maxHealth
|
||||
scannedDevice.specialWeaponsOnReentry = _currentGameConfig.value.specialWeaponsOnReentry
|
||||
addOrRefreshDevice(scannedDevice)
|
||||
val info = DeviceInfo(
|
||||
name = hello.device_name,
|
||||
deviceType = hello.device_type,
|
||||
deviceTypeName = Packet.getDeviceTypeName(hello.device_type),
|
||||
majorVersion = hello.systemK_major_version,
|
||||
minorVersion = hello.systemK_minor_version
|
||||
)
|
||||
if (deviceInfoRepository.setInfo(scannedDevice.address, info)) {
|
||||
mqttSync.publishCurrentInfo()
|
||||
}
|
||||
}
|
||||
Packet.IsKTagStatusPacket(result) -> {
|
||||
val status = Packet.Status(result)
|
||||
scannedDevice.broadcastedTeam = status.team_ID.toInt()
|
||||
when (status.getSystemK_top_level_state()) {
|
||||
3 -> scannedDevice.deviceState = DeviceState.Ready
|
||||
7 -> scannedDevice.deviceState = DeviceState.Playing
|
||||
9 -> scannedDevice.deviceState = DeviceState.WrapUp
|
||||
}
|
||||
addOrRefreshDevice(scannedDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val oldDevice = currentDevices[index]
|
||||
newDevice.name = oldDevice.name
|
||||
newDevice.deviceType = oldDevice.deviceType ?: newDevice.deviceType
|
||||
newDevice.team = oldDevice.team ?: newDevice.team
|
||||
newDevice.broadcastedTeam = newDevice.broadcastedTeam ?: oldDevice.broadcastedTeam
|
||||
newDevice.playerID = oldDevice.playerID ?: newDevice.playerID
|
||||
newDevice.deviceState = newDevice.deviceState ?: oldDevice.deviceState
|
||||
newDevice.systemkMajorVersion = newDevice.systemkMajorVersion ?: oldDevice.systemkMajorVersion
|
||||
newDevice.systemkMinorVersion = newDevice.systemkMinorVersion ?: oldDevice.systemkMinorVersion
|
||||
newDevice.maxHealth = oldDevice.maxHealth ?: newDevice.maxHealth
|
||||
newDevice.broadcastedMaxHealth = newDevice.broadcastedMaxHealth ?: oldDevice.broadcastedMaxHealth
|
||||
newDevice.specialWeaponsOnReentry = oldDevice.specialWeaponsOnReentry ?: newDevice.specialWeaponsOnReentry
|
||||
newDevice.broadcastedSpecialWeaponsOnReentry = newDevice.broadcastedSpecialWeaponsOnReentry ?: oldDevice.broadcastedSpecialWeaponsOnReentry
|
||||
currentDevices[index] = newDevice
|
||||
}
|
||||
_devices.value = currentDevices
|
||||
Log.d("AddRefresh", _devices.value.toString())
|
||||
_allDevicesReady.value = allDevicesReady()
|
||||
Log.d("AddRefresh", _allDevicesReady.value.toString())
|
||||
}
|
||||
|
||||
/** Applies [update] to the device with the given [address] and marks settings as pending. */
|
||||
private fun updateDeviceField(address: String, update: (Device) -> Device) {
|
||||
_devices.update { list -> list.map { if (it.address == address) update(it) else it } }
|
||||
_pendingApply.value = true
|
||||
}
|
||||
|
||||
fun updateDeviceTeam(deviceAddress: String, newTeam: Int) {
|
||||
updateDeviceField(deviceAddress) { it.copy(team = newTeam) }
|
||||
_allDevicesReady.value = allDevicesReady()
|
||||
}
|
||||
|
||||
fun renameDevice(address: String, name: String) {
|
||||
deviceInfoRepository.setName(address, name)
|
||||
mqttSync.publishCurrentInfo()
|
||||
}
|
||||
|
||||
fun getDeviceUserDefinedName(address: String): String {
|
||||
return deviceInfoRepository.getInfo(address)?.userDefinedName ?: ""
|
||||
}
|
||||
|
||||
fun updateDeviceMaxHealth(deviceAddress: String, value: Int) =
|
||||
updateDeviceField(deviceAddress) { it.copy(maxHealth = value) }
|
||||
|
||||
fun updateDeviceSpecialWeapons(deviceAddress: String, value: Int) =
|
||||
updateDeviceField(deviceAddress) { it.copy(specialWeaponsOnReentry = value) }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private var readyRetryJob: Job? = null
|
||||
|
||||
fun readyAllDevices() {
|
||||
readyRetryJob?.cancel()
|
||||
readyRetryJob = viewModelScope.launch {
|
||||
while (_appState.value is AppState.TeamSettings && !_allDevicesReady.value) {
|
||||
val broadcastAddr = HexUtils.hexStringToByteArray("FF:FF:FF:FF:FF:FF", ':')
|
||||
val eventPacket = Packet.Event(broadcastAddr, Packet.EVENT_CONFIGURED, 0)
|
||||
bleManager.startAdvertising(eventPacket.GetBytes(), advertisingCallbackDefault)
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGameConfig(newGameConfig: GameConfig) {
|
||||
_currentGameConfig.value = newGameConfig
|
||||
sharedPrefs.edit()
|
||||
.putString(GamePreferenceKeys.GAME_DURATION, newGameConfig.gameDurationMin.toString())
|
||||
.putString(GamePreferenceKeys.TIME_UNTIL_COUNTDOWN, newGameConfig.timeUntilCountdownS.toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the configuration of all devices using the MultiDeviceConfigurator.
|
||||
*/
|
||||
fun configureDevices() {
|
||||
val currentDevicesToConfigure = _devices.value
|
||||
val gameCfg = _currentGameConfig.value
|
||||
|
||||
if (currentDevicesToConfigure.isEmpty()) {
|
||||
Log.i(TAG_STATE_MACHINE, "No devices eligible for configuration.")
|
||||
_configurationProgress.value = ConfigurationProgress(0, 0, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the packet list and a parallel list of confirmation lambdas.
|
||||
// Each lambda is applied to the device when its specific packet is ACK'd,
|
||||
// so a failed packet never blocks confirmation of packets that did succeed.
|
||||
val confirmOnSuccess = mutableListOf<(Device) -> Device>()
|
||||
val deviceConfigPackets = currentDevicesToConfigure.flatMap { device ->
|
||||
val targetAddr = HexUtils.hexStringToByteArray(device.address, ':')
|
||||
val teamId = device.team ?: 0
|
||||
val health = device.maxHealth ?: gameCfg.maxHealth
|
||||
val specWeapons = device.specialWeaponsOnReentry ?: gameCfg.specialWeaponsOnReentry
|
||||
confirmOnSuccess += { d -> d.copy(
|
||||
broadcastedTeam = d.team ?: d.broadcastedTeam,
|
||||
broadcastedMaxHealth = d.maxHealth ?: d.broadcastedMaxHealth
|
||||
)}
|
||||
confirmOnSuccess += { d -> d.copy(
|
||||
broadcastedSpecialWeaponsOnReentry = d.specialWeaponsOnReentry ?: d.broadcastedSpecialWeaponsOnReentry
|
||||
)}
|
||||
listOf(
|
||||
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
|
||||
Packet.PARAMETER_KEY_TEAM_ID, teamId,
|
||||
Packet.PARAMETER_KEY_MAX_HEALTH, health)),
|
||||
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
|
||||
Packet.PARAMETER_KEY_SPECIAL_WEAPONS_ON_REENTRY, specWeapons,
|
||||
Packet.PARAMETER_KEY_NONE, 0))
|
||||
)
|
||||
}
|
||||
|
||||
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: ${deviceConfigPackets.size} packets for ${currentDevicesToConfigure.size} devices.")
|
||||
readyRetryJob?.cancel()
|
||||
_isConfiguring.value = true
|
||||
_pendingApply.value = false
|
||||
_deviceConfigStatus.value = emptyMap() // Reset status for the new session
|
||||
_configurationProgress.value = ConfigurationProgress(0, deviceConfigPackets.size, false)
|
||||
|
||||
viewModelScope.launch {
|
||||
var packetIndex = 0
|
||||
val results = multiDeviceConfigurator.configureMultipleDevices(
|
||||
deviceConfigs = deviceConfigPackets,
|
||||
onDeviceConfigured = { deviceAddress, success ->
|
||||
val confirm = confirmOnSuccess.getOrNull(packetIndex++)
|
||||
Log.i(TAG_STATE_MACHINE, "Device $deviceAddress packet ${packetIndex - 1} configured: $success")
|
||||
_deviceConfigStatus.update { it + (deviceAddress to success) }
|
||||
if (success && confirm != null) {
|
||||
_devices.update { list ->
|
||||
list.map { device ->
|
||||
if (device.address == deviceAddress) confirm(device) else device
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onProgressUpdate = { configuredCount, totalCount ->
|
||||
Log.i(TAG_STATE_MACHINE, "Configuration progress: $configuredCount/$totalCount")
|
||||
_configurationProgress.value = ConfigurationProgress(configuredCount, totalCount, configuredCount == totalCount)
|
||||
}
|
||||
)
|
||||
|
||||
// 3. Process overall results
|
||||
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).")
|
||||
}
|
||||
|
||||
// Resume device discovery. configureDevice() stops scanning in its finally block.
|
||||
bleManager.startScanning(scanCallbackDefault)
|
||||
_isConfiguring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun nextState() {
|
||||
soundManager.playDoubleChirp()
|
||||
when (_appState.value) {
|
||||
is AppState.GameSettings -> {
|
||||
_appState.value = AppState.TeamSettings
|
||||
_pendingApply.value = true
|
||||
bleManager.startScanning(scanCallbackDefault)
|
||||
}
|
||||
is AppState.TeamSettings -> {
|
||||
readyRetryJob?.cancel()
|
||||
_appState.value = AppState.PregameTimer
|
||||
val instigatePacket = Packet.InstigatingGame(
|
||||
gameLengthMs.toInt(),
|
||||
pregameLengthMs.toInt()
|
||||
)
|
||||
bleManager.stopScanning()
|
||||
bleManager.startAdvertising(instigatePacket.GetBytes(), advertisingCallbackDefault)
|
||||
}
|
||||
is AppState.PregameTimer -> {
|
||||
_appState.value = AppState.Countdown
|
||||
bleManager.stopAdvertising()
|
||||
}
|
||||
is AppState.Countdown -> {
|
||||
_appState.value = AppState.GameTimer
|
||||
}
|
||||
is AppState.GameTimer -> {
|
||||
_appState.value = AppState.WrapUp
|
||||
val broadcastAddr = HexUtils.hexStringToByteArray("FF:FF:FF:FF:FF:FF", ':')
|
||||
val gameOverPacket = Packet.Event(broadcastAddr, Packet.EVENT_GAME_OVER, 0)
|
||||
bleManager.startAdvertising(gameOverPacket.GetBytes(), advertisingCallbackDefault)
|
||||
}
|
||||
AppState.WrapUp -> {
|
||||
_appState.value = AppState.GameSettings
|
||||
val broadcastAddr = HexUtils.hexStringToByteArray("FF:FF:FF:FF:FF:FF", ':')
|
||||
val wrapupCompletePacket = Packet.Event(broadcastAddr, Packet.EVENT_WRAPUP_COMPLETE, 2)
|
||||
bleManager.startAdvertising(wrapupCompletePacket.GetBytes(), advertisingCallbackDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package club.clubk.ktag.apps.konfigurator
|
||||
|
||||
/** Minimum SystemK firmware version required for full Konfigurator support. */
|
||||
object MinSystemKVersion {
|
||||
const val MAJOR = 1
|
||||
const val MINOR = 2
|
||||
|
||||
/** Human-readable form, e.g. "1.02". */
|
||||
val display: String get() = "$MAJOR.${MINOR.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package club.clubk.ktag.apps.konfigurator.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package club.clubk.ktag.apps.konfigurator.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
import club.clubk.ktag.apps.core.ui.theme.Typography
|
||||
|
||||
private val KTagColorScheme = darkColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = Color.Black,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = Color.Black,
|
||||
background = Color.Black,
|
||||
surface = Color.Black,
|
||||
onBackground = Color.White,
|
||||
onSurface = Color.White,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KonfiguratorTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = KTagColorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
BIN
subapp-konfigurator/src/main/res/drawable/konfigurator.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 980 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
subapp-konfigurator/src/main/res/raw/double_chirp.wav
Normal file
10
subapp-konfigurator/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="konfigurator_icon_background">#000000</color>
|
||||
</resources>
|
||||
3
subapp-konfigurator/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Konfigurator</string>
|
||||
</resources>
|
||||
5
subapp-konfigurator/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Konfigurator" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
subapp-konfigurator/src/main/res/xml/backup_rules.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||