Initial public release.

This commit is contained in:
Joe Kearney 2026-03-01 17:03:10 -06:00
parent ed31acd60f
commit 58d87b11b7
249 changed files with 15831 additions and 4 deletions

View file

@ -0,0 +1,53 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.sharedservices"
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":core"))
implementation(libs.androidx.core.ktx)
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.preference:preference:1.2.1")
// Compose runtime (required by kotlin.compose plugin)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Coroutines for StateFlow
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// MQTT
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
implementation("com.github.hannesa2:paho.mqtt.android:4.4.2")
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Activity must be declared by consuming subapps with their own parentActivityName -->
</manifest>

View file

@ -0,0 +1,66 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.annotation.XmlRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
/**
* Base activity for displaying preference screens.
* Subapps can use this directly via [createIntent] or subclass for custom behavior.
*/
open class BaseSettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
val preferencesResId = intent.getIntExtra(EXTRA_PREFERENCES_RES_ID, 0)
if (preferencesResId != 0) {
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, BaseSettingsFragment.newInstance(preferencesResId))
.commit()
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
NavUtils.navigateUpFromSameTask(this)
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
internal const val EXTRA_PREFERENCES_RES_ID = "preferences_res_id"
/**
* Create an Intent to launch the settings activity with the given preferences XML.
*/
@JvmStatic
fun createIntent(context: Context, @XmlRes preferencesResId: Int): Intent {
return createIntent(context, preferencesResId, BaseSettingsActivity::class.java)
}
/**
* Create an Intent to launch a settings activity subclass with the given preferences XML.
*/
@JvmStatic
fun <T : BaseSettingsActivity> createIntent(
context: Context,
@XmlRes preferencesResId: Int,
activityClass: Class<T>
): Intent {
return Intent(context, activityClass).apply {
putExtra(EXTRA_PREFERENCES_RES_ID, preferencesResId)
}
}
}
}

View file

@ -0,0 +1,30 @@
package club.clubk.ktag.apps.sharedservices
import android.os.Bundle
import androidx.annotation.XmlRes
import androidx.preference.PreferenceFragmentCompat
/**
* Base fragment for displaying preferences from an XML resource.
*/
open class BaseSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val preferencesResId = arguments?.getInt(ARG_PREFERENCES_RES_ID, 0) ?: 0
if (preferencesResId != 0) {
setPreferencesFromResource(preferencesResId, rootKey)
}
}
companion object {
private const val ARG_PREFERENCES_RES_ID = "preferences_res_id"
fun newInstance(@XmlRes preferencesResId: Int): BaseSettingsFragment {
return BaseSettingsFragment().apply {
arguments = Bundle().apply {
putInt(ARG_PREFERENCES_RES_ID, preferencesResId)
}
}
}
}
}

View file

@ -0,0 +1,196 @@
package club.clubk.ktag.apps.sharedservices
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import club.clubk.ktag.apps.core.DeviceModel
import club.clubk.ktag.apps.core.ble.Packet
import kotlinx.coroutines.launch
/**
* Base ViewModel for subapps that scan for KTag BLE devices.
*
* Provides shared BLE scanning/advertising infrastructure and DeviceInfo integration.
* Subclasses must implement [onStatusPacket] to handle Status packet content and
* [updateDeviceInfoFromRepository] to apply name/version updates to their device list.
*/
abstract class BleViewModel : ViewModel() {
protected abstract val logTag: String
// BLE
protected var btScanner: BluetoothLeScanner? = null
protected var btAdvertiser: BluetoothLeAdvertiser? = null
protected var isAdvertising = false
protected var advertisingJob: kotlinx.coroutines.Job? = null
// Device Info
protected var deviceInfoRepository: DeviceInfoRepository? = null
protected var deviceInfoMqttSync: DeviceInfoMqttSync? = null
// Context for UI interactions (e.g. Toast)
protected var appContext: Context? = null
/** Listener that shows incoming MQTT Listen messages as a Toast. */
val listenListener = object : MqttMessageListener {
override fun onMessageReceived(topic: String, payload: ByteArray) {
val text = String(payload)
Handler(Looper.getMainLooper()).post {
appContext?.let { ctx ->
Toast.makeText(ctx, "MQTT: $text", Toast.LENGTH_LONG).show()
}
}
}
}
@SuppressLint("MissingPermission")
protected val advertisingCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d(logTag, "Advertising onStartSuccess")
isAdvertising = true
}
override fun onStartFailure(errorCode: Int) {
Log.e(logTag, "Advertising onStartFailure: $errorCode")
isAdvertising = false
}
}
@SuppressLint("MissingPermission")
protected val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (Packet.IsKTagStatusPacket(result)) {
onStatusPacket(result)
} else if (Packet.IsKTagHelloPacket(result)) {
val hello = Packet.Hello(result)
val changed = deviceInfoRepository?.setInfo(
result.device.address,
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
)
) ?: false
if (changed) {
deviceInfoMqttSync?.publishCurrentInfo()
}
}
}
override fun onBatchScanResults(results: List<ScanResult>) {
results.forEach { onScanResult(0, it) }
}
override fun onScanFailed(errorCode: Int) {
Log.e(logTag, "BLE Scan failed with error code: $errorCode")
}
}
/** Called when a Status packet is received; subclass handles the payload. */
protected abstract fun onStatusPacket(result: ScanResult)
@SuppressLint("MissingPermission")
protected fun initBluetooth(context: Context) {
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
btScanner = btAdapter?.bluetoothLeScanner
btAdvertiser = btAdapter?.bluetoothLeAdvertiser
}
protected fun initDeviceInfo(context: Context) {
try {
deviceInfoRepository = DeviceInfoRepository.getInstance(context)
deviceInfoMqttSync = DeviceInfoMqttSync(deviceInfoRepository!!)
deviceInfoMqttSync?.connect()
viewModelScope.launch {
deviceInfoRepository?.deviceInfo?.collect { infoMap ->
updateDeviceInfoFromRepository(infoMap)
}
}
} catch (e: Exception) {
Log.e(logTag, "Failed to initialize device info: ${e.message}")
}
}
/**
* Called when the DeviceInfo repository updates. Subclass should call
* [applyDeviceInfoUpdate] with its own device list.
*/
protected abstract fun updateDeviceInfoFromRepository(infoMap: Map<String, DeviceInfo>)
/**
* Applies name/type/version updates from [infoMap] to [devices] in place.
*/
protected fun applyDeviceInfoUpdate(
devices: MutableList<DeviceModel>,
infoMap: Map<String, DeviceInfo>
) {
val iterator = devices.listIterator()
while (iterator.hasNext()) {
val device = iterator.next()
val info = infoMap[device.bleAddress] ?: continue
val newName = info.displayName.ifEmpty { null } ?: device.name
val newDeviceType = info.deviceTypeName
val newVersion = String.format("SystemK v%d.%02d", info.majorVersion, info.minorVersion)
if (device.name != newName || device.deviceType != newDeviceType || device.version != newVersion) {
iterator.set(device.copy(name = newName, deviceType = newDeviceType, version = newVersion))
}
}
}
fun renameDevice(macAddress: String, newName: String) {
deviceInfoRepository?.setName(macAddress, newName)
deviceInfoMqttSync?.publishCurrentInfo()
}
@SuppressLint("MissingPermission")
protected fun startBleScanning() {
try {
btScanner?.startScan(scanCallback)
Log.i(logTag, "Started scanning for KTag devices.")
} catch (e: Exception) {
Log.e(logTag, "Error starting scan: ", e)
}
}
@SuppressLint("MissingPermission")
protected fun stopBleScanning() {
try {
btScanner?.stopScan(scanCallback)
Log.i(logTag, "Stopped scanning for KTag devices.")
} catch (e: Exception) {
Log.e(logTag, "Error stopping scan: ", e)
}
}
@SuppressLint("MissingPermission")
protected fun stopAdvertising() {
try {
btAdvertiser?.stopAdvertising(advertisingCallback)
} catch (e: Exception) {
Log.e(logTag, "Error stopping advertising: ", e)
}
isAdvertising = false
advertisingJob?.cancel()
advertisingJob = null
}
protected fun cleanupDeviceInfo() {
deviceInfoMqttSync?.cleanup()
}
}

View file

@ -0,0 +1,75 @@
package club.clubk.ktag.apps.sharedservices
import android.util.Log
import org.json.JSONObject
/**
* Handles MQTT synchronization of device info across devices.
* Publishes info changes as retained messages and subscribes to receive updates.
*/
class DeviceInfoMqttSync(
private val repository: DeviceInfoRepository
) : MqttMessageListener {
companion object {
private const val TAG = "DeviceInfoMqttSync"
private const val QOS = 1
}
private val topic: String
get() = "KTag/${SharedMqttClient.battlefield}/DeviceInfo"
/**
* Subscribe to device info topic via the shared MQTT client.
*/
fun connect() {
SharedMqttClient.subscribe(topic, this)
}
override fun onMessageReceived(topic: String, payload: ByteArray) {
handleIncomingMessage(payload)
}
private fun handleIncomingMessage(payload: ByteArray) {
try {
val json = String(payload)
val jsonObject = JSONObject(json)
val incoming = mutableMapOf<String, DeviceInfo>()
jsonObject.keys().forEach { key ->
val infoObj = jsonObject.getJSONObject(key)
incoming[key] = DeviceInfo(
name = infoObj.optString("name", ""),
userDefinedName = infoObj.optString("userDefinedName", ""),
deviceType = infoObj.optInt("deviceType", 0),
deviceTypeName = infoObj.optString("deviceTypeName", ""),
majorVersion = infoObj.optInt("majorVersion", 0),
minorVersion = infoObj.optInt("minorVersion", 0)
)
}
if (incoming.isNotEmpty()) {
Log.i(TAG, "Received ${incoming.size} device info entries from MQTT")
repository.mergeInfo(incoming)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse incoming device info: ${e.message}")
}
}
/**
* Publish current device info to MQTT as a retained message.
*/
fun publishCurrentInfo() {
val json = repository.getAllInfoAsJson()
SharedMqttClient.publish(topic, json.toByteArray(), QOS, retained = true)
Log.i(TAG, "Published device info to $topic")
}
/**
* Cleanup MQTT resources.
*/
fun cleanup() {
SharedMqttClient.unsubscribe(topic, this)
}
}

View file

@ -0,0 +1,9 @@
package club.clubk.ktag.apps.sharedservices
/**
* Preference keys for device info storage.
*/
object DeviceInfoPreferenceKeys {
/** Key for JSON blob containing MAC address to DeviceInfo mappings */
const val DEVICE_INFO_JSON = "device_info_json"
}

View file

@ -0,0 +1,181 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
/**
* Device info extracted from Hello packets.
*/
data class DeviceInfo(
val name: String = "",
val userDefinedName: String = "",
val deviceType: Int = 0,
val deviceTypeName: String = "",
val majorVersion: Int = 0,
val minorVersion: Int = 0
) {
/** Returns userDefinedName if set, otherwise name. */
val displayName: String
get() = userDefinedName.ifEmpty { name }
}
/**
* Singleton repository for managing device info stored in SharedPreferences.
* Provides reactive updates via StateFlow for Compose UI integration.
*/
class DeviceInfoRepository private constructor(context: Context) {
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val _deviceInfo = MutableStateFlow<Map<String, DeviceInfo>>(emptyMap())
/** Reactive stream of device info mappings (MAC address -> DeviceInfo) */
val deviceInfo: StateFlow<Map<String, DeviceInfo>> = _deviceInfo.asStateFlow()
private val prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == DeviceInfoPreferenceKeys.DEVICE_INFO_JSON) {
_deviceInfo.value = loadInfoFromPrefs()
}
}
init {
_deviceInfo.value = loadInfoFromPrefs()
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
}
private fun loadInfoFromPrefs(): Map<String, DeviceInfo> {
val json = prefs.getString(DeviceInfoPreferenceKeys.DEVICE_INFO_JSON, null)
?: return emptyMap()
return try {
val jsonObject = JSONObject(json)
val map = mutableMapOf<String, DeviceInfo>()
jsonObject.keys().forEach { key ->
val infoObj = jsonObject.getJSONObject(key)
map[key] = DeviceInfo(
name = infoObj.optString("name", ""),
userDefinedName = infoObj.optString("userDefinedName", ""),
deviceType = infoObj.optInt("deviceType", 0),
deviceTypeName = infoObj.optString("deviceTypeName", ""),
majorVersion = infoObj.optInt("majorVersion", 0),
minorVersion = infoObj.optInt("minorVersion", 0)
)
}
map
} catch (e: Exception) {
emptyMap()
}
}
private fun saveInfoToPrefs(info: Map<String, DeviceInfo>) {
val jsonObject = JSONObject()
info.forEach { (mac, deviceInfo) ->
val infoObj = JSONObject().apply {
put("name", deviceInfo.name)
put("userDefinedName", deviceInfo.userDefinedName)
put("deviceType", deviceInfo.deviceType)
put("deviceTypeName", deviceInfo.deviceTypeName)
put("majorVersion", deviceInfo.majorVersion)
put("minorVersion", deviceInfo.minorVersion)
}
jsonObject.put(mac, infoObj)
}
prefs.edit()
.putString(DeviceInfoPreferenceKeys.DEVICE_INFO_JSON, jsonObject.toString())
.apply()
}
/**
* Get the info for a device by MAC address.
*/
fun getInfo(macAddress: String): DeviceInfo? {
return _deviceInfo.value[macAddress]
}
/**
* Get the name for a device by MAC address.
*/
fun getName(macAddress: String): String {
val info = _deviceInfo.value[macAddress] ?: return "KTag Device $macAddress"
val display = info.displayName
return display.ifEmpty { "KTag Device $macAddress" }
}
/**
* Set or update the user-defined name for a device.
*/
fun setName(macAddress: String, name: String) {
val existing = _deviceInfo.value[macAddress] ?: DeviceInfo()
if (existing.userDefinedName == name) return
val updated = _deviceInfo.value.toMutableMap()
updated[macAddress] = existing.copy(userDefinedName = name)
saveInfoToPrefs(updated)
_deviceInfo.value = updated
}
/**
* Set or update the info for a device.
* Returns true if the info actually changed.
*/
fun setInfo(macAddress: String, info: DeviceInfo): Boolean {
val existing = _deviceInfo.value[macAddress]
val merged = if (existing != null) {
info.copy(userDefinedName = existing.userDefinedName)
} else {
info
}
if (existing == merged) return false
val updated = _deviceInfo.value.toMutableMap()
updated[macAddress] = merged
saveInfoToPrefs(updated)
_deviceInfo.value = updated
return true
}
/**
* Merge incoming info with existing info.
* Incoming info takes precedence.
*/
fun mergeInfo(incoming: Map<String, DeviceInfo>) {
val merged = _deviceInfo.value.toMutableMap()
merged.putAll(incoming)
saveInfoToPrefs(merged)
_deviceInfo.value = merged
}
/**
* Get all device info as a JSON string for MQTT publishing.
*/
fun getAllInfoAsJson(): String {
val jsonObject = JSONObject()
_deviceInfo.value.forEach { (mac, info) ->
val infoObj = JSONObject().apply {
put("name", info.name)
put("userDefinedName", info.userDefinedName)
put("deviceType", info.deviceType)
put("deviceTypeName", info.deviceTypeName)
put("majorVersion", info.majorVersion)
put("minorVersion", info.minorVersion)
}
jsonObject.put(mac, infoObj)
}
return jsonObject.toString()
}
companion object {
@Volatile
private var instance: DeviceInfoRepository? = null
fun getInstance(context: Context): DeviceInfoRepository {
return instance ?: synchronized(this) {
instance ?: DeviceInfoRepository(context.applicationContext).also {
instance = it
}
}
}
}
}

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for device detection settings.
* These keys must match the android:key attributes in broker settings XML.
*/
object DevicePreferenceKeys {
const val DEVICE_TTL = "device_ttl_s"
const val DEFAULT_DEVICE_TTL = "5" // seconds
}

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for game timing settings shared across subapps.
* These keys must match the android:key attributes in settings XML files.
*/
object GamePreferenceKeys {
const val GAME_DURATION = "game_duration_min" // stored as minutes
const val DEFAULT_GAME_DURATION = "10"
const val TIME_UNTIL_COUNTDOWN = "countdown_delay_s" // stored as seconds
const val DEFAULT_TIME_UNTIL_COUNTDOWN = "30"
}

View file

@ -0,0 +1,129 @@
package club.clubk.ktag.apps.sharedservices
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
object LocationPublisher {
private const val TAG = "LocationPublisher"
private const val UPDATE_INTERVAL_MS = 30_000L
private const val MIN_DISTANCE_M = 0f
private var locationManager: LocationManager? = null
private var locationListener: LocationListener? = null
private var appContext: Context? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var statusJob: Job? = null
fun start(context: Context) {
appContext = context.applicationContext
locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
statusJob?.cancel()
statusJob = scope.launch {
SharedMqttClient.status.collect { status ->
if (status is MqttClientStatus.Connected) {
requestLocationUpdates()
} else {
stopLocationUpdates()
}
}
}
}
fun stop() {
statusJob?.cancel()
statusJob = null
stopLocationUpdates()
appContext = null
}
private fun requestLocationUpdates() {
val ctx = appContext ?: return
val lm = locationManager ?: return
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
) {
Log.w(TAG, "Location permission not granted, skipping GPS publishing")
return
}
if (locationListener != null) return // already registered
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
publishLocation(location)
}
@Deprecated("Deprecated in API 29")
override fun onStatusChanged(provider: String, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
locationListener = listener
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.filter { lm.isProviderEnabled(it) }
if (providers.isEmpty()) {
Log.w(TAG, "No location providers enabled")
locationListener = null
return
}
for (provider in providers) {
try {
lm.requestLocationUpdates(provider, UPDATE_INTERVAL_MS, MIN_DISTANCE_M, listener, Looper.getMainLooper())
Log.i(TAG, "Registered location updates from $provider")
} catch (e: Exception) {
Log.e(TAG, "Failed to register $provider: ${e.message}")
}
}
// Publish last known location immediately
providers.firstNotNullOfOrNull { lm.getLastKnownLocation(it) }?.let {
publishLocation(it)
}
}
private fun stopLocationUpdates() {
val listener = locationListener ?: return
locationManager?.removeUpdates(listener)
locationListener = null
Log.d(TAG, "Stopped location updates")
}
private fun publishLocation(location: Location) {
val battlefield = SharedMqttClient.battlefield
val deviceId = SharedMqttClient.currentDeviceId
val topic = "KTag/$battlefield/Devices/$deviceId/Location"
val payload = buildString {
append("{")
append("\"lat\":").append(location.latitude)
append(",\"lon\":").append(location.longitude)
if (location.hasAltitude()) append(",\"alt\":").append(location.altitude)
if (location.hasAccuracy()) append(",\"accuracy\":").append(location.accuracy)
append("}")
}
SharedMqttClient.publish(topic, payload.toByteArray(), qos = 1, retained = true)
Log.d(TAG, "Published location to $topic: $payload")
}
}

View file

@ -0,0 +1,48 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import androidx.preference.PreferenceManager
/**
* Enum representing the auto-heal setting options.
*/
enum class AutoHealMode(val value: Int) {
NONE(0),
RED(1),
BLUE(2),
ALL(3);
companion object {
fun fromInt(value: Int): AutoHealMode = entries.find { it.value == value } ?: NONE
}
}
/**
* Data class holding Medic configuration loaded from SharedPreferences.
*/
data class MedicConfig(
val minRssi: Int,
val autoHeal: AutoHealMode
) {
companion object {
/**
* Load Medic configuration from the app's default SharedPreferences.
*/
@JvmStatic
fun fromPreferences(context: Context): MedicConfig {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return MedicConfig(
minRssi = prefs.getString(
MedicPreferenceKeys.MIN_RSSI,
MedicPreferenceKeys.DEFAULT_MIN_RSSI
)?.toIntOrNull() ?: MedicPreferenceKeys.DEFAULT_MIN_RSSI.toInt(),
autoHeal = AutoHealMode.fromInt(
prefs.getString(
MedicPreferenceKeys.AUTO_HEAL,
MedicPreferenceKeys.DEFAULT_AUTO_HEAL
)?.toIntOrNull() ?: MedicPreferenceKeys.DEFAULT_AUTO_HEAL.toInt()
)
)
}
}
}

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for Medic settings.
* These keys must match the android:key attributes in medic preference XML files.
*/
object MedicPreferenceKeys {
const val MIN_RSSI = "min_rssi"
const val AUTO_HEAL = "auto_heal"
const val DEFAULT_MIN_RSSI = "-60"
const val DEFAULT_AUTO_HEAL = "0" // 0=None, 1=Red, 2=Blue, 3=All
}

View file

@ -0,0 +1,52 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import androidx.preference.PreferenceManager
/**
* Enum representing the target team setting options.
*/
enum class TargetTeamMode(val value: Int) {
RED(1),
BLUE(2),
ALL(3);
companion object {
fun fromInt(value: Int): TargetTeamMode = entries.find { it.value == value } ?: ALL
}
}
/**
* Data class holding Mine configuration loaded from SharedPreferences.
*/
data class MineConfig(
val minRssi: Int,
val damage: Int,
val targetTeam: TargetTeamMode
) {
companion object {
/**
* Load Mine configuration from the app's default SharedPreferences.
*/
@JvmStatic
fun fromPreferences(context: Context): MineConfig {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return MineConfig(
minRssi = prefs.getString(
MinePreferenceKeys.MIN_RSSI,
MinePreferenceKeys.DEFAULT_MIN_RSSI
)?.toIntOrNull() ?: MinePreferenceKeys.DEFAULT_MIN_RSSI.toInt(),
damage = prefs.getString(
MinePreferenceKeys.DAMAGE,
MinePreferenceKeys.DEFAULT_DAMAGE
)?.toIntOrNull() ?: MinePreferenceKeys.DEFAULT_DAMAGE.toInt(),
targetTeam = TargetTeamMode.fromInt(
prefs.getString(
MinePreferenceKeys.TARGET_TEAM,
MinePreferenceKeys.DEFAULT_TARGET_TEAM
)?.toIntOrNull() ?: MinePreferenceKeys.DEFAULT_TARGET_TEAM.toInt()
)
)
}
}
}

View file

@ -0,0 +1,17 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for Mine settings.
* These keys must match the android:key attributes in mine preference XML files.
*/
object MinePreferenceKeys {
const val MIN_RSSI = "mine_min_rssi"
const val DAMAGE = "mine_damage"
const val TARGET_TEAM = "mine_target_team"
const val REARM_TIME = "mine_rearm_time"
const val DEFAULT_MIN_RSSI = "-60"
const val DEFAULT_DAMAGE = "100"
const val DEFAULT_TARGET_TEAM = "3" // 1=Red, 2=Blue, 3=All
const val DEFAULT_REARM_TIME = "5" // seconds
}

View file

@ -0,0 +1,45 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import androidx.preference.PreferenceManager
import java.util.UUID
/**
* Data class holding MQTT connection configuration loaded from SharedPreferences.
*/
data class MqttConnectionConfig(
val deviceId: String,
val serverUri: String,
val username: String,
val password: String,
val battlefield: String,
val autodiscovery: Boolean
) {
companion object {
/**
* Load MQTT configuration from the app's default SharedPreferences.
*/
@JvmStatic
fun fromPreferences(context: Context): MqttConnectionConfig {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return MqttConnectionConfig(
deviceId = prefs.getString(MqttPreferenceKeys.DEVICE_ID, null)
?.takeIf { it.isNotBlank() }
?: UUID.randomUUID().toString().also {
prefs.edit().putString(MqttPreferenceKeys.DEVICE_ID, it).apply()
},
serverUri = prefs.getString(
MqttPreferenceKeys.SERVER_URI,
MqttPreferenceKeys.DEFAULT_SERVER_URI
) ?: MqttPreferenceKeys.DEFAULT_SERVER_URI,
username = prefs.getString(MqttPreferenceKeys.USERNAME, "") ?: "",
password = prefs.getString(MqttPreferenceKeys.PASSWORD, "") ?: "",
battlefield = prefs.getString(
MqttPreferenceKeys.BATTLEFIELD,
MqttPreferenceKeys.DEFAULT_BATTLEFIELD
) ?: MqttPreferenceKeys.DEFAULT_BATTLEFIELD,
autodiscovery = prefs.getBoolean(MqttPreferenceKeys.AUTODISCOVERY, true)
)
}
}
}

View file

@ -0,0 +1,17 @@
package club.clubk.ktag.apps.sharedservices
/**
* Centralized preference keys for MQTT settings.
* These keys must match the android:key attributes in preference XML files.
*/
object MqttPreferenceKeys {
const val DEVICE_ID = "mqtt_device_id"
const val SERVER_URI = "mqtt_server_uri"
const val USERNAME = "mqtt_username"
const val PASSWORD = "mqtt_password"
const val BATTLEFIELD = "mqtt_battlefield"
const val AUTODISCOVERY = "mqtt_autodiscovery"
const val DEFAULT_SERVER_URI = "ssl://mqtt.clubk.club:8883"
const val DEFAULT_BATTLEFIELD = "My Yard"
}

View file

@ -0,0 +1,11 @@
package club.clubk.ktag.apps.sharedservices
import android.content.SharedPreferences
/** Reads a String preference and converts it to Int, falling back to [default]. */
fun SharedPreferences.getIntPref(key: String, default: String): Int =
getString(key, default)?.toIntOrNull() ?: default.toInt()
/** Reads a String preference and converts it to Long, falling back to [default]. */
fun SharedPreferences.getLongPref(key: String, default: String): Long =
getString(key, default)?.toLongOrNull() ?: default.toLong()

View file

@ -0,0 +1,33 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.content.Intent
import androidx.annotation.XmlRes
import club.clubk.ktag.apps.core.SubApp
/**
* Extended SubApp interface for subapps that provide user-configurable settings.
*
* Subapps implementing this interface gain access to common settings infrastructure
* including BaseSettingsActivity and shared preference utilities.
*/
interface SettingsSubApp : SubApp {
/**
* The XML resource ID for this subapp's preferences.
*/
@get:XmlRes
val settingsPreferencesResId: Int
/**
* Creates an Intent to launch the settings activity for this subapp.
*/
fun createSettingsIntent(context: Context): Intent {
return BaseSettingsActivity.createIntent(context, settingsPreferencesResId)
}
/**
* Whether this subapp uses MQTT functionality.
*/
val usesMqtt: Boolean
get() = false
}

View file

@ -0,0 +1,411 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.net.nsd.NsdManager
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.eclipse.paho.client.mqttv3.IMqttActionListener
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.IMqttToken
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttClient
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import info.mqtt.android.service.Ack
import info.mqtt.android.service.MqttAndroidClient
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
sealed class MqttClientStatus {
data object Disconnected : MqttClientStatus()
data object Connecting : MqttClientStatus()
data object Connected : MqttClientStatus()
data class Error(val message: String) : MqttClientStatus()
}
interface MqttMessageListener {
fun onMessageReceived(topic: String, payload: ByteArray)
}
object SharedMqttClient {
private const val TAG = "SharedMqttClient"
private const val DISCOVERY_TIMEOUT_MS = 5000L
private const val DISCOVERY_RETRY_DELAY_MS = 5000L
@Volatile
private var autodiscoveryActive = false
private val _status = MutableStateFlow<MqttClientStatus>(MqttClientStatus.Disconnected)
val status: StateFlow<MqttClientStatus> = _status.asStateFlow()
private var client: MqttAndroidClient? = null
private var currentConfig: MqttConnectionConfig? = null
// topic -> set of listeners
private val subscriptions = ConcurrentHashMap<String, MutableSet<MqttMessageListener>>()
val battlefield: String
get() = currentConfig?.battlefield ?: MqttPreferenceKeys.DEFAULT_BATTLEFIELD
val currentDeviceId: String
get() = currentConfig?.deviceId ?: android.os.Build.MODEL
fun connect(context: Context) {
val config = MqttConnectionConfig.fromPreferences(context)
if (config.autodiscovery) {
connectWithAutodiscovery(context, config)
} else {
connectWithConfig(context, config)
}
}
private fun connectWithConfig(context: Context, config: MqttConnectionConfig) {
// If already connected with same config, no-op
if (client?.isConnected == true && config == currentConfig) {
Log.d(TAG, "Already connected with same config, skipping")
return
}
// Tear down existing connection
disconnectInternal()
currentConfig = config
_status.value = MqttClientStatus.Connecting
connectToServer(context, config.serverUri, config.username, config.password)
}
private fun connectWithAutodiscovery(context: Context, config: MqttConnectionConfig) {
// For autodiscovery, the effective credentials are KTag / battlefield
val effectiveUsername = "KTag"
val effectivePassword = config.battlefield
// If already connected via autodiscovery with same battlefield, no-op
if (client?.isConnected == true && currentConfig?.autodiscovery == true
&& currentConfig?.battlefield == config.battlefield
&& currentConfig?.deviceId == config.deviceId) {
Log.d(TAG, "Already connected via autodiscovery with same battlefield, skipping")
return
}
disconnectInternal()
currentConfig = config
_status.value = MqttClientStatus.Connecting
autodiscoveryActive = true
// Run mDNS discovery on a background thread, retrying until found
Thread {
while (autodiscoveryActive) {
val discoveredUri = discoverBroker(context)
if (discoveredUri != null) {
Log.i(TAG, "Autodiscovery found broker at $discoveredUri")
if (autodiscoveryActive) {
connectToServer(context, discoveredUri, effectiveUsername, effectivePassword)
}
return@Thread
}
Log.w(TAG, "Autodiscovery found no brokers on LAN, retrying in ${DISCOVERY_RETRY_DELAY_MS}ms")
_status.value = MqttClientStatus.Error("No broker found on LAN, retrying\u2026")
try {
Thread.sleep(DISCOVERY_RETRY_DELAY_MS)
} catch (_: InterruptedException) {
return@Thread
}
}
}.start()
}
private fun discoverBroker(context: Context): String? {
val nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager
if (nsdManager == null) {
Log.e(TAG, "NSD service not available")
return null
}
val latch = CountDownLatch(1)
var resultUri: String? = null
val discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "mDNS discovery started")
}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val callback = object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.w(TAG, "ServiceInfoCallback registration failed: $errorCode")
}
override fun onServiceUpdated(si: NsdServiceInfo) {
nsdManager.unregisterServiceInfoCallback(this)
handleResolvedService(si, resultUri) { uri ->
resultUri = uri
latch.countDown()
}
}
override fun onServiceLost() {
nsdManager.unregisterServiceInfoCallback(this)
}
override fun onServiceInfoCallbackUnregistered() {}
}
nsdManager.registerServiceInfoCallback(serviceInfo, { it.run() }, callback)
} else {
@Suppress("DEPRECATION")
nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(si: NsdServiceInfo, errorCode: Int) {
Log.w(TAG, "Failed to resolve service: $errorCode")
}
override fun onServiceResolved(si: NsdServiceInfo) {
handleResolvedService(si, resultUri) { uri ->
resultUri = uri
latch.countDown()
}
}
})
}
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "mDNS discovery start failed: $errorCode")
latch.countDown()
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
}
nsdManager.discoverServices("_mqtt._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
// Wait for first result or timeout
latch.await(DISCOVERY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
try {
nsdManager.stopServiceDiscovery(discoveryListener)
} catch (_: Exception) {}
return resultUri
}
private fun handleResolvedService(
si: NsdServiceInfo,
currentResult: String?,
onFound: (String) -> Unit
) {
val purpose = si.attributes["purpose"]?.let { String(it) }
if (purpose != "KTag MQTT Broker") {
Log.d(TAG, "Skipping non-KTag broker: ${si.serviceName} (purpose=$purpose)")
return
}
val host = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
si.hostAddresses.firstOrNull()?.hostAddress
} else {
@Suppress("DEPRECATION")
si.host?.hostAddress
} ?: return
val port = si.port
Log.i(TAG, "Resolved KTag MQTT broker: $host:$port")
if (currentResult == null) {
onFound("tcp://$host:$port")
}
}
private fun connectToServer(context: Context, serverUri: String, username: String, password: String) {
try {
val clientId = currentConfig?.deviceId ?: android.os.Build.MODEL
val newClient = MqttAndroidClient(
context.applicationContext,
serverUri,
clientId,
Ack.AUTO_ACK
)
newClient.setCallback(object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String) {
Log.i(TAG, "Connected to $serverURI (reconnect=$reconnect)")
_status.value = MqttClientStatus.Connected
resubscribeAll()
}
override fun connectionLost(cause: Throwable?) {
Log.w(TAG, "Connection lost: ${cause?.message}")
_status.value = MqttClientStatus.Disconnected
}
override fun messageArrived(topic: String, message: MqttMessage) {
val listeners = subscriptions[topic]
if (listeners != null) {
synchronized(listeners) {
for (listener in listeners) {
try {
listener.onMessageReceived(topic, message.payload)
} catch (e: Exception) {
Log.e(TAG, "Error in message listener: ${e.message}")
}
}
}
}
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {}
})
val options = MqttConnectOptions().apply {
isCleanSession = true
isAutomaticReconnect = true
keepAliveInterval = 15
userName = username
this.password = password.toCharArray()
}
newClient.connect(options)?.setActionCallback(object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken?) {
Log.i(TAG, "MQTT connection successful")
}
override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) {
Log.e(TAG, "MQTT connection failed: ${exception?.message}")
_status.value = MqttClientStatus.Error(
exception?.message ?: "Connection failed"
)
}
})
client = newClient
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize MQTT: ${e.message}")
_status.value = MqttClientStatus.Error(e.message ?: "Initialization failed")
}
}
fun disconnect() {
disconnectInternal()
_status.value = MqttClientStatus.Disconnected
}
private fun disconnectInternal() {
autodiscoveryActive = false
try {
client?.unregisterResources()
client?.disconnect()
} catch (e: Exception) {
Log.e(TAG, "Error during disconnect: ${e.message}")
}
client = null
}
fun subscribe(topic: String, listener: MqttMessageListener) {
val listeners = subscriptions.getOrPut(topic) { mutableSetOf() }
val needsBrokerSubscribe: Boolean
synchronized(listeners) {
needsBrokerSubscribe = listeners.isEmpty()
listeners.add(listener)
}
if (needsBrokerSubscribe) {
subscribeToBroker(topic)
}
}
fun unsubscribe(topic: String, listener: MqttMessageListener) {
val listeners = subscriptions[topic] ?: return
val needsBrokerUnsubscribe: Boolean
synchronized(listeners) {
listeners.remove(listener)
needsBrokerUnsubscribe = listeners.isEmpty()
}
if (needsBrokerUnsubscribe) {
subscriptions.remove(topic)
unsubscribeFromBroker(topic)
}
}
/**
* Publishes a timestamped hello message to "KTag/<battlefield>/<topicPath>/Hello".
* [appName] is used as the subject of the message, e.g. "KTag Medic App".
*/
fun publishHello(appName: String, topicPath: String) {
try {
val bf = battlefield
val sdf = SimpleDateFormat("EEE, MMM d, yyyy HH:mm", Locale.getDefault())
val helloString = "$appName started on ${sdf.format(Date())}."
publish("KTag/$bf/$topicPath/Hello", helloString.toByteArray(), qos = 2, retained = false)
} catch (e: Exception) {
Log.e(TAG, "Failed to publish hello: ${e.message}")
}
}
fun publish(topic: String, payload: ByteArray, qos: Int = 1, retained: Boolean = false) {
val c = client
if (c == null || !c.isConnected) {
Log.w(TAG, "Not connected, skipping publish to $topic")
return
}
try {
val message = MqttMessage(payload).apply {
this.qos = qos
isRetained = retained
}
c.publish(topic, message)
Log.d(TAG, "Published to $topic")
} catch (e: Exception) {
Log.e(TAG, "Failed to publish to $topic: ${e.message}")
}
}
private fun subscribeToBroker(topic: String) {
val c = client
if (c == null || !c.isConnected) {
Log.d(TAG, "Not connected, will subscribe to $topic on reconnect")
return
}
try {
c.subscribe(topic, 1)?.setActionCallback(object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken?) {
Log.i(TAG, "Subscribed to $topic")
}
override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) {
Log.e(TAG, "Failed to subscribe to $topic: ${exception?.message}")
}
})
} catch (e: Exception) {
Log.e(TAG, "Error subscribing to $topic: ${e.message}")
}
}
private fun unsubscribeFromBroker(topic: String) {
val c = client
if (c == null || !c.isConnected) return
try {
c.unsubscribe(topic)
} catch (e: Exception) {
Log.e(TAG, "Error unsubscribing from $topic: ${e.message}")
}
}
private fun resubscribeAll() {
for (topic in subscriptions.keys) {
subscribeToBroker(topic)
}
}
}

View file

@ -0,0 +1,47 @@
package club.clubk.ktag.apps.sharedservices
import android.content.Context
import android.util.AttributeSet
import androidx.preference.EditTextPreference
/**
* An EditTextPreference that displays its current value as the summary,
* with optional custom hint text when value is empty.
*/
class SummarizedEditTextPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.editTextPreferenceStyle,
defStyleRes: Int = 0
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
private val customHint: CharSequence?
init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.SummarizedEditTextPreference,
defStyleAttr,
defStyleRes
)
try {
customHint = typedArray.getString(R.styleable.SummarizedEditTextPreference_customHint)
} finally {
typedArray.recycle()
}
}
override fun getSummary(): CharSequence? {
val text = text
return if (text.isNullOrEmpty()) {
customHint ?: "Not set"
} else {
val summaryPattern = super.getSummary()
if (summaryPattern != null) {
String.format(summaryPattern.toString(), text)
} else {
text
}
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/settings_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SummarizedEditTextPreference">
<attr name="customHint" format="string" />
</declare-styleable>
</resources>