Initial public release.
This commit is contained in:
parent
ed31acd60f
commit
58d87b11b7
249 changed files with 15831 additions and 4 deletions
44
subapp-medic/src/main/AndroidManifest.xml
Normal file
44
subapp-medic/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".MedicActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".MedicSettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/action_settings"
|
||||
android:parentActivityName=".MedicActivity"
|
||||
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".MedicActivity"/>
|
||||
</activity>
|
||||
|
||||
<service android:name="org.eclipse.paho.android.service.MqttService" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="club.clubk.ktag.apps.medic.MedicInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package club.clubk.ktag.apps.medic
|
||||
|
||||
/**
|
||||
* Auto-heal settings.
|
||||
*/
|
||||
enum class AutoHealSetting(val value: Int) {
|
||||
NONE(0),
|
||||
RED(1),
|
||||
BLUE(2),
|
||||
ALL(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): AutoHealSetting =
|
||||
entries.find { it.value == value } ?: NONE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
package club.clubk.ktag.apps.medic
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
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.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import club.clubk.ktag.apps.medic.ui.MedicScreen
|
||||
import club.clubk.ktag.apps.medic.ui.theme.MedicTheme
|
||||
|
||||
private const val TAG = "KTag Medic"
|
||||
|
||||
class MedicActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MedicViewModel by viewModels()
|
||||
|
||||
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,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
} 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")
|
||||
showMainContent()
|
||||
} else {
|
||||
val deniedPermissions = permissions.filter { !it.value }.keys
|
||||
Log.w(TAG, "Some permissions denied: $deniedPermissions")
|
||||
showPermissionDeniedContent(deniedPermissions.toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(TAG, "onCreate called")
|
||||
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
|
||||
checkAndRequestPermissions()
|
||||
}
|
||||
|
||||
private fun checkAndRequestPermissions() {
|
||||
Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
|
||||
if (hasRequiredPermissions()) {
|
||||
Log.i(TAG, "All required permissions already granted")
|
||||
showMainContent()
|
||||
} 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)
|
||||
}
|
||||
|
||||
private fun showMainContent() {
|
||||
setContent {
|
||||
MedicTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MedicMainScreen(
|
||||
viewModel = viewModel,
|
||||
context = this,
|
||||
onSettingsClick = { openSettings() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPermissionDeniedContent(deniedPermissions: List<String>) {
|
||||
setContent {
|
||||
MedicTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
PermissionDeniedScreen(
|
||||
deniedPermissions = deniedPermissions,
|
||||
onRequestPermissions = { requestPermissions() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = MedicSettingsActivity.createIntent(this)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (hasRequiredPermissions()) {
|
||||
viewModel.loadPreferences(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (hasRequiredPermissions()) {
|
||||
viewModel.startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.stopScanning()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewModel.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MedicMainScreen(
|
||||
viewModel: MedicViewModel,
|
||||
context: Context,
|
||||
onSettingsClick: () -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// Initialize ViewModel and handle BLE scanning lifecycle
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.initialize(context)
|
||||
viewModel.startScanning()
|
||||
onDispose {
|
||||
viewModel.stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_settings)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onSettingsClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
MedicScreen(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionDeniedScreen(
|
||||
deniedPermissions: List<String>,
|
||||
onRequestPermissions: () -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "KTag Medic needs the following permissions:",
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.medic
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import club.clubk.ktag.apps.core.SubAppRegistry
|
||||
|
||||
class MedicInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
SubAppRegistry.register(MedicSubApp())
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package club.clubk.ktag.apps.medic
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity
|
||||
|
||||
class MedicSettingsActivity : BaseSettingsActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return BaseSettingsActivity.createIntent(context, R.xml.medic_settings_pref, MedicSettingsActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package club.clubk.ktag.apps.medic
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.sharedservices.SettingsSubApp
|
||||
|
||||
class MedicSubApp : SettingsSubApp {
|
||||
override val id = "medic"
|
||||
override val name = "Medic"
|
||||
override val icon = R.drawable.ic_medic
|
||||
override val settingsPreferencesResId = R.xml.medic_settings_pref
|
||||
override val usesMqtt = true
|
||||
|
||||
override fun createIntent(context: Context): Intent {
|
||||
return Intent(context, MedicActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
package club.clubk.ktag.apps.medic
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
import club.clubk.ktag.apps.core.HexUtils
|
||||
import club.clubk.ktag.apps.core.R
|
||||
import club.clubk.ktag.apps.core.ble.Packet
|
||||
import club.clubk.ktag.apps.core.ui.ColorUtils
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
import club.clubk.ktag.apps.sharedservices.BleViewModel
|
||||
import club.clubk.ktag.apps.sharedservices.DeviceInfo
|
||||
import club.clubk.ktag.apps.sharedservices.DevicePreferenceKeys
|
||||
import club.clubk.ktag.apps.sharedservices.MedicPreferenceKeys
|
||||
import club.clubk.ktag.apps.sharedservices.SharedMqttClient
|
||||
import club.clubk.ktag.apps.sharedservices.getIntPref
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MedicViewModel : BleViewModel() {
|
||||
|
||||
override val logTag = "KTag Medic"
|
||||
|
||||
companion object {
|
||||
private const val CLEANUP_INTERVAL_MS = 500L
|
||||
private const val RSSI_HYSTERESIS = 20
|
||||
private const val HEAL_DURATION_MS = 3000L
|
||||
private const val HEAL_RETRY_INTERVAL_MS = 5000L
|
||||
private const val HEAL_COOLDOWN_MS = 4000L
|
||||
}
|
||||
|
||||
// Device list
|
||||
private val _devices = mutableStateListOf<DeviceModel>()
|
||||
val devices: List<DeviceModel> get() = _devices
|
||||
|
||||
// KOTH possession state
|
||||
var kothPossessionText by mutableStateOf("The game has not started.")
|
||||
private set
|
||||
|
||||
var kothPossessionColor by mutableStateOf(Color.White)
|
||||
private set
|
||||
|
||||
// Settings
|
||||
var autoHealSetting by mutableStateOf(AutoHealSetting.NONE)
|
||||
private set
|
||||
|
||||
var minimumRssi by mutableStateOf(-60)
|
||||
private set
|
||||
|
||||
private var deviceTtlMs = DeviceModel.DEFAULT_TTL_MS
|
||||
|
||||
val backgroundColorForAutoHeal: Color
|
||||
get() = when (autoHealSetting) {
|
||||
AutoHealSetting.RED -> ColorUtils.makePastel(KTagRed)
|
||||
AutoHealSetting.BLUE -> ColorUtils.makePastel(KTagBlue)
|
||||
AutoHealSetting.ALL -> ColorUtils.makePastel(KTagPurple)
|
||||
AutoHealSetting.NONE -> Color.White
|
||||
}
|
||||
|
||||
// Cleanup job
|
||||
private var cleanupJob: Job? = null
|
||||
|
||||
// Heal retry job
|
||||
private var healRetryJob: Job? = null
|
||||
|
||||
// Track last heal time per device to avoid spamming
|
||||
private val lastHealedAt = mutableMapOf<String, Long>()
|
||||
|
||||
private val possessionListener = object : club.clubk.ktag.apps.sharedservices.MqttMessageListener {
|
||||
override fun onMessageReceived(topic: String, payload: ByteArray) {
|
||||
val message = String(payload)
|
||||
onPossessionUpdated(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStatusPacket(result: ScanResult) {
|
||||
val status = Packet.Status(result)
|
||||
addOrRefreshDevice(
|
||||
bleAddress = result.device.address,
|
||||
rssi = result.rssi,
|
||||
color = Color(status.primary_color),
|
||||
health = status.health
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateDeviceInfoFromRepository(infoMap: Map<String, DeviceInfo>) {
|
||||
applyDeviceInfoUpdate(_devices, infoMap)
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
initBluetooth(context)
|
||||
loadPreferences(context)
|
||||
initMqtt()
|
||||
initDeviceInfo(context)
|
||||
}
|
||||
|
||||
private fun initMqtt() {
|
||||
try {
|
||||
val battlefield = SharedMqttClient.battlefield
|
||||
SharedMqttClient.publishHello("KTag Medic App", "Medic")
|
||||
SharedMqttClient.subscribe("KTag/$battlefield/KotH/Listen", listenListener)
|
||||
SharedMqttClient.subscribe("KTag/$battlefield/KotH/Possession", possessionListener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to initialize MQTT: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPreferences(context: Context) {
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
minimumRssi = sharedPrefs.getIntPref(MedicPreferenceKeys.MIN_RSSI, MedicPreferenceKeys.DEFAULT_MIN_RSSI)
|
||||
autoHealSetting = AutoHealSetting.fromInt(
|
||||
sharedPrefs.getIntPref(MedicPreferenceKeys.AUTO_HEAL, MedicPreferenceKeys.DEFAULT_AUTO_HEAL)
|
||||
)
|
||||
deviceTtlMs = sharedPrefs.getIntPref(DevicePreferenceKeys.DEVICE_TTL, DevicePreferenceKeys.DEFAULT_DEVICE_TTL) * 1000
|
||||
Log.d(logTag, "minimumRSSI: $minimumRssi, autoHealSetting: $autoHealSetting")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScanning() {
|
||||
startBleScanning()
|
||||
|
||||
cleanupJob?.cancel()
|
||||
cleanupJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(CLEANUP_INTERVAL_MS)
|
||||
cleanupExpiredDevices()
|
||||
}
|
||||
}
|
||||
|
||||
healRetryJob?.cancel()
|
||||
healRetryJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(HEAL_RETRY_INTERVAL_MS)
|
||||
retryHealingIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScanning() {
|
||||
stopBleScanning()
|
||||
cleanupJob?.cancel()
|
||||
cleanupJob = null
|
||||
healRetryJob?.cancel()
|
||||
healRetryJob = null
|
||||
stopAdvertising()
|
||||
}
|
||||
|
||||
private fun cleanupExpiredDevices() {
|
||||
val iterator = _devices.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val device = iterator.next()
|
||||
val updated = device.withDecrementedTtl(CLEANUP_INTERVAL_MS.toInt())
|
||||
if (updated.isExpired) {
|
||||
iterator.remove()
|
||||
lastHealedAt.remove(device.bleAddress)
|
||||
} else {
|
||||
iterator.set(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addOrRefreshDevice(
|
||||
bleAddress: String,
|
||||
rssi: Int,
|
||||
color: Color,
|
||||
health: Int
|
||||
) {
|
||||
val shouldAutoHeal = when (autoHealSetting) {
|
||||
AutoHealSetting.ALL -> true
|
||||
AutoHealSetting.RED -> color == Color.Red
|
||||
AutoHealSetting.BLUE -> color == Color.Blue
|
||||
AutoHealSetting.NONE -> false
|
||||
}
|
||||
|
||||
val existingIndex = _devices.indexOfFirst { it.bleAddress == bleAddress }
|
||||
|
||||
if (existingIndex == -1) {
|
||||
// Device not in list
|
||||
Log.d(logTag, "New device: $bleAddress, health=$health, rssi=$rssi, minRssi=$minimumRssi")
|
||||
if (health <= 0 && rssi >= minimumRssi) {
|
||||
// Tagged-out device, close enough to show
|
||||
if (shouldAutoHeal) {
|
||||
healDevice(bleAddress)
|
||||
}
|
||||
val deviceName = deviceInfoRepository?.getName(bleAddress) ?: "KTag Device"
|
||||
val info = deviceInfoRepository?.getInfo(bleAddress)
|
||||
Log.d(logTag, "Adding device: $bleAddress with name: $deviceName")
|
||||
_devices.add(DeviceModel(
|
||||
name = deviceName,
|
||||
version = if (info != null) String.format("SystemK v%d.%02d", info.majorVersion, info.minorVersion) else "",
|
||||
deviceType = info?.deviceTypeName ?: "",
|
||||
id = 1,
|
||||
image = R.drawable.ktag_shield,
|
||||
bleAddress = bleAddress,
|
||||
rssi = rssi,
|
||||
color = color
|
||||
))
|
||||
}
|
||||
} else {
|
||||
// Device already in list
|
||||
if (health <= 0 && rssi >= (minimumRssi - RSSI_HYSTERESIS)) {
|
||||
// Still tagged out and close enough; refresh TTL.
|
||||
_devices[existingIndex] = _devices[existingIndex].withResetTtl(deviceTtlMs)
|
||||
} else {
|
||||
// Device is alive or too far; remove from list.
|
||||
Log.d(logTag, "Removing device: $bleAddress")
|
||||
_devices.removeAt(existingIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun healDevice(bleAddress: String) {
|
||||
Log.i(logTag, "Healing $bleAddress")
|
||||
|
||||
lastHealedAt[bleAddress] = System.currentTimeMillis()
|
||||
|
||||
val settings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(false)
|
||||
.build()
|
||||
|
||||
val tag = Packet.Tag(HexUtils.hexStringToByteArray(bleAddress, ':'))
|
||||
val adData = AdvertiseData.Builder()
|
||||
.addManufacturerData(0xFFFF, tag.GetBytes())
|
||||
.build()
|
||||
|
||||
stopAdvertising()
|
||||
|
||||
btAdvertiser?.startAdvertising(settings, adData, advertisingCallback)
|
||||
|
||||
advertisingJob = viewModelScope.launch {
|
||||
delay(HEAL_DURATION_MS)
|
||||
stopAdvertising()
|
||||
}
|
||||
}
|
||||
|
||||
private fun retryHealingIfNeeded() {
|
||||
if (autoHealSetting == AutoHealSetting.NONE) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
for (device in _devices.toList()) {
|
||||
val shouldAutoHeal = when (autoHealSetting) {
|
||||
AutoHealSetting.ALL -> true
|
||||
AutoHealSetting.RED -> device.color == Color.Red
|
||||
AutoHealSetting.BLUE -> device.color == Color.Blue
|
||||
AutoHealSetting.NONE -> false
|
||||
}
|
||||
|
||||
if (shouldAutoHeal) {
|
||||
val lastHealed = lastHealedAt[device.bleAddress] ?: 0L
|
||||
if (now - lastHealed >= HEAL_COOLDOWN_MS) {
|
||||
Log.d(logTag, "Retrying heal for ${device.bleAddress}")
|
||||
healDevice(device.bleAddress)
|
||||
return // Only heal one device per cycle to avoid BLE conflicts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeviceClicked(device: DeviceModel) {
|
||||
healDevice(device.bleAddress)
|
||||
_devices.remove(device)
|
||||
}
|
||||
|
||||
private fun onPossessionUpdated(message: String) {
|
||||
kothPossessionText = message
|
||||
kothPossessionColor = when {
|
||||
message.contains("Red") -> KTagRed
|
||||
message.contains("Blue") -> KTagBlue
|
||||
else -> Color.White
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
stopScanning()
|
||||
stopAdvertising()
|
||||
try {
|
||||
val battlefield = SharedMqttClient.battlefield
|
||||
SharedMqttClient.unsubscribe("KTag/$battlefield/KotH/Listen", listenListener)
|
||||
SharedMqttClient.unsubscribe("KTag/$battlefield/KotH/Possession", possessionListener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Error unsubscribing: ${e.message}")
|
||||
}
|
||||
cleanupDeviceInfo()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package club.clubk.ktag.apps.medic.ui
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DeviceCard(
|
||||
device: DeviceModel,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.border(2.dp, device.color, RoundedCornerShape(10.dp))
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
onLongClick()
|
||||
}
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Device icon with color background
|
||||
Image(
|
||||
painter = painterResource(id = device.image),
|
||||
contentDescription = "Device icon",
|
||||
modifier = Modifier
|
||||
.size(75.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(device.color)
|
||||
)
|
||||
|
||||
// Device info
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = device.bleAddress,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = device.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
if (device.deviceType.isNotEmpty()) {
|
||||
Text(
|
||||
text = device.deviceType,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = device.version,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package club.clubk.ktag.apps.medic.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import club.clubk.ktag.apps.medic.R
|
||||
|
||||
@Composable
|
||||
fun KothStatusBar(
|
||||
possessionText: String,
|
||||
possessionColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Flag image with colored background
|
||||
Box(
|
||||
modifier = Modifier.size(100.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Color indicator behind the flag
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(75.dp)
|
||||
.background(possessionColor)
|
||||
)
|
||||
// King of the Hill flag image
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.king_of_the_hill),
|
||||
contentDescription = "King of the Hill",
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Possession text
|
||||
Text(
|
||||
text = possessionText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp, end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package club.clubk.ktag.apps.medic.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import club.clubk.ktag.apps.core.DeviceModel
|
||||
import club.clubk.ktag.apps.core.ui.RenameDeviceDialog
|
||||
import club.clubk.ktag.apps.medic.MedicViewModel
|
||||
import club.clubk.ktag.apps.medic.ui.theme.MedicGrey
|
||||
|
||||
@Composable
|
||||
fun MedicScreen(
|
||||
viewModel: MedicViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val devices = viewModel.devices
|
||||
val kothPossessionText = viewModel.kothPossessionText
|
||||
val kothPossessionColor = viewModel.kothPossessionColor
|
||||
val backgroundColor = viewModel.backgroundColorForAutoHeal
|
||||
|
||||
// Rename dialog state
|
||||
var deviceToRename by remember { mutableStateOf<DeviceModel?>(null) }
|
||||
|
||||
// Rename dialog
|
||||
deviceToRename?.let { device ->
|
||||
RenameDeviceDialog(
|
||||
macAddress = device.bleAddress,
|
||||
currentName = device.name,
|
||||
onDismiss = { deviceToRename = null },
|
||||
onSave = { newName ->
|
||||
viewModel.renameDevice(device.bleAddress, newName)
|
||||
deviceToRename = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MedicGrey)
|
||||
) {
|
||||
// KOTH Status Bar
|
||||
KothStatusBar(
|
||||
possessionText = kothPossessionText,
|
||||
possessionColor = kothPossessionColor
|
||||
)
|
||||
|
||||
// Device list
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
items(
|
||||
items = devices,
|
||||
key = { it.bleAddress }
|
||||
) { device ->
|
||||
DeviceCard(
|
||||
device = device,
|
||||
onClick = { viewModel.onDeviceClicked(device) },
|
||||
onLongClick = { deviceToRename = device }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.medic.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
|
||||
// Medic color aliases for clarity in module-specific code
|
||||
val MedicGrey = Color(0xFFE0E0E0)
|
||||
val MedicWhite = Color(0xFFFEFEFE)
|
||||
val MedicRed = KTagRed
|
||||
val MedicBlue = KTagBlue
|
||||
val MedicPurple = KTagPurple
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package club.clubk.ktag.apps.medic.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
import club.clubk.ktag.apps.core.ui.theme.Typography
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MedicTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
BIN
subapp-medic/src/main/res/drawable/ic_medic.png
Normal file
BIN
subapp-medic/src/main/res/drawable/ic_medic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
BIN
subapp-medic/src/main/res/drawable/king_of_the_hill.png
Normal file
BIN
subapp-medic/src/main/res/drawable/king_of_the_hill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
15
subapp-medic/src/main/res/values/arrays.xml
Normal file
15
subapp-medic/src/main/res/values/arrays.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="pref_auto_heal_list_titles">
|
||||
<item>Off</item>
|
||||
<item>Red</item>
|
||||
<item>Blue</item>
|
||||
<item>All</item>
|
||||
</string-array>
|
||||
<string-array name="pref_auto_heal_list_values">
|
||||
<item>0</item>
|
||||
<item>1</item>
|
||||
<item>2</item>
|
||||
<item>3</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
16
subapp-medic/src/main/res/values/colors.xml
Normal file
16
subapp-medic/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- KTag brand colors -->
|
||||
<color name="ktag_green">#4BA838</color>
|
||||
<color name="ktag_blue">#4D6CFA</color>
|
||||
<color name="ktag_red">#F34213</color>
|
||||
<color name="ktag_yellow">#FFC857</color>
|
||||
<color name="ktag_dark_gray">#323031</color>
|
||||
|
||||
<!-- App colors -->
|
||||
<color name="grey_300">#E0E0E0</color>
|
||||
<color name="color_white">#fefefe</color>
|
||||
<color name="color_red">#F34213</color>
|
||||
<color name="color_blue">#4D6CFA</color>
|
||||
<color name="color_purple">#ff00ff</color>
|
||||
</resources>
|
||||
5
subapp-medic/src/main/res/values/dimens.xml
Normal file
5
subapp-medic/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<resources>
|
||||
<!-- Default screen margins, per the Android Design guidelines. -->
|
||||
<dimen name="activity_horizontal_margin">16dp</dimen>
|
||||
<dimen name="activity_vertical_margin">16dp</dimen>
|
||||
</resources>
|
||||
5
subapp-medic/src/main/res/values/strings.xml
Normal file
5
subapp-medic/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<resources>
|
||||
<string name="app_name">KTag Medic</string>
|
||||
<string name="hello_world">Hello world!</string>
|
||||
<string name="action_settings">Settings</string>
|
||||
</resources>
|
||||
8
subapp-medic/src/main/res/values/styles.xml
Normal file
8
subapp-medic/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
22
subapp-medic/src/main/res/xml/medic_settings_pref.xml
Normal file
22
subapp-medic/src/main/res/xml/medic_settings_pref.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<PreferenceCategory android:title="Medic Settings">
|
||||
|
||||
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
|
||||
app:customHint='"-120" (far) to "0" (nearby); try "-60"'
|
||||
android:inputType="numberSigned"
|
||||
android:key="min_rssi"
|
||||
android:summary="%s dBm"
|
||||
android:title="Minimum RSSI (dBm) for Device Detection" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/pref_auto_heal_list_titles"
|
||||
android:entryValues="@array/pref_auto_heal_list_values"
|
||||
android:defaultValue="0"
|
||||
android:key="auto_heal"
|
||||
android:title="Auto-Heal Detected Devices"
|
||||
android:summary="Auto-Heal %s" />
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
Loading…
Add table
Add a link
Reference in a new issue