Initial public release.
This commit is contained in:
parent
ed31acd60f
commit
58d87b11b7
249 changed files with 15831 additions and 4 deletions
53
shared-services/build.gradle.kts
Normal file
53
shared-services/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
4
shared-services/src/main/AndroidManifest.xml
Normal file
4
shared-services/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
6
shared-services/src/main/res/values/attrs.xml
Normal file
6
shared-services/src/main/res/values/attrs.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue