A collection of KTag applications and tools in a single Android app.
Find a file
2026-03-01 17:03:10 -06:00
app Initial public release. 2026-03-01 17:03:10 -06:00
core Initial public release. 2026-03-01 17:03:10 -06:00
gradle Initial public release. 2026-03-01 17:03:10 -06:00
mqtt-broker Initial public release. 2026-03-01 17:03:10 -06:00
shared-services Initial public release. 2026-03-01 17:03:10 -06:00
subapp-bletool Initial public release. 2026-03-01 17:03:10 -06:00
subapp-konfigurator Initial public release. 2026-03-01 17:03:10 -06:00
subapp-koth Initial public release. 2026-03-01 17:03:10 -06:00
subapp-medic Initial public release. 2026-03-01 17:03:10 -06:00
subapp-mine Initial public release. 2026-03-01 17:03:10 -06:00
subapp-sample Initial public release. 2026-03-01 17:03:10 -06:00
subapp-sentry Initial public release. 2026-03-01 17:03:10 -06:00
subapp-terminal Initial public release. 2026-03-01 17:03:10 -06:00
.gitignore Initial public release. 2026-03-01 17:03:10 -06:00
build.gradle.kts Initial public release. 2026-03-01 17:03:10 -06:00
gradle.properties Initial public release. 2026-03-01 17:03:10 -06:00
LICENSE Initial public release. 2026-03-01 17:03:10 -06:00
README.md Initial public release. 2026-03-01 17:03:10 -06:00
settings.gradle.kts Initial public release. 2026-03-01 17:03:10 -06:00

KTag Apps

A modular Android app that serves as a launcher for a collection of related subapps. The main screen displays a grid of icons, each launching an independent subapp. Each subapp is its own Gradle module, making it easy to develop and add new ones without touching existing code.

Project Structure

  • app — Main launcher module. Displays the subapp grid and handles navigation.
  • core — Shared interfaces (SubApp, SubAppRegistry) that all modules depend on.
  • shared-services — Common settings infrastructure (SettingsSubApp, BaseSettingsActivity, SummarizedEditTextPreference) for subapps with user preferences. Also provides SharedMqttClient, a singleton MQTT client shared by all subapps, DeviceInfoMqttSync for cross-device info synchronization, and LocationPublisher for GPS position reporting.
  • subapp-sample — Example subapp demonstrating the full pattern.
  • subapp-bletool — Tool for debugging KTag BLE issues. Identical functionality to the old Android BLE Tool.
  • subapp-koth — App for hosting King of the Hill games (with MQTT).
  • subapp-medic — App for simulating a medic (with proximity-based healing and MQTT).
  • subapp-terminal — USB serial terminal for communicating with KTag devices using usb-serial-for-android.
  • subapp-mine — App for automatically tagging nearby devices via BLE.
  • subapp-konfigurator — App for configuring KTag laser tag devices via BLE and coordinating game sessions.
  • subapp-sentry — Autonomous sentry gun app that uses the device camera and on-device ML (MediaPipe / EfficientDet-Lite0) to detect people and fire the laser tag gun via USB serial when a person intersects the crosshair.
  • mqtt-broker — Embedded MQTT broker module using Moquette. Runs as a foreground service with optional SSL, authentication, and mDNS discovery. Also hosts the unified "MQTT Settings" page (client connection settings, broker settings, and mDNS broker discovery). Configured via the overflow menu in the main launcher.

Building

Open the project in Android Studio and sync Gradle, or run:

./gradlew assembleDebug

KTag Colors

The KTag color palette should be used consistently across all subapps:

Color Hex Usage
Green #4BA838 Success states, positive indicators
Blue #4D6CFA Blue team, links, interactive elements
Red #F34213 Red team, warnings, destructive actions
Yellow #FFC857 Highlights, accents
Purple #9B59B6 All teams, combined team indicators
Dark Gray #323031 Backgrounds, text, icons

These colors are defined in each module's Color.kt file (e.g., KTagGreen, KTagBlue, KTagRed, KTagYellow, KTagPurple, KTagDarkGray).

Adding a New SubApp

1. Create the module directory

subapp-yourname/
└── src/main/
    ├── AndroidManifest.xml
    ├── java/club/clubk/ktag/apps/yourname/
    │   ├── YourSubApp.kt
    │   ├── YourActivity.kt
    │   └── YourInitializer.kt
    └── res/drawable/
        └── ic_yourname.xml

2. Add build.gradle.kts

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "club.clubk.ktag.apps.yourname"
    compileSdk = 34

    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(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.startup)
}

3. Implement the SubApp interface

// YourSubApp.kt
package club.clubk.ktag.apps.yourname

import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.core.SubApp

class YourSubApp : SubApp {
    override val id = "yourname"
    override val name = "Your App"
    override val icon = R.drawable.ic_yourname

    override fun createIntent(context: Context): Intent {
        return Intent(context, YourActivity::class.java)
    }
}

4. Create the entry Activity

// YourActivity.kt
package club.clubk.ktag.apps.yourname

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class YourActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // Your UI here
        }
    }
}

5. Create the Initializer

This registers your subapp with the launcher automatically on app startup:

// YourInitializer.kt
package club.clubk.ktag.apps.yourname

import android.content.Context
import androidx.startup.Initializer
import club.clubk.ktag.apps.core.SubAppRegistry

class YourInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        SubAppRegistry.register(YourSubApp())
    }

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

6. Declare the Activity and Initializer in AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <activity
            android:name=".YourActivity"
            android:exported="false"
            android:label="Your App" />

        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false">
            <meta-data
                android:name="club.clubk.ktag.apps.yourname.YourInitializer"
                android:value="androidx.startup" />
        </provider>
    </application>
</manifest>

7. Add a drawable icon

Place a vector drawable at src/main/res/drawable/ic_yourname.xml. This is what appears in the launcher grid.

8. Register the module with the project

In settings.gradle.kts, add:

include(":subapp-yourname")

In app/build.gradle.kts, add the dependency:

implementation(project(":subapp-yourname"))

Sync Gradle and run the app — your new subapp will appear in the launcher grid.

Adding Settings to a SubApp

If your subapp needs user-configurable settings, use the shared-services module.

1. Add the dependency

In your subapp's build.gradle.kts:

dependencies {
    implementation(project(":core"))
    implementation(project(":shared-services"))
    // ... other dependencies
}

2. Implement SettingsSubApp instead of SubApp

// YourSubApp.kt
package club.clubk.ktag.apps.yourname

import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.sharedservices.SettingsSubApp

class YourSubApp : SettingsSubApp {
    override val id = "yourname"
    override val name = "Your App"
    override val icon = R.drawable.ic_yourname
    override val settingsPreferencesResId = R.xml.settings_pref
    override val usesMqtt = true  // set to false if not using MQTT

    override fun createIntent(context: Context): Intent {
        return Intent(context, YourActivity::class.java)
    }
}

3. Create a settings activity

// YourSettingsActivity.kt
package club.clubk.ktag.apps.yourname

import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.sharedservices.BaseSettingsActivity

class YourSettingsActivity : BaseSettingsActivity() {
    companion object {
        @JvmStatic
        fun createIntent(context: Context): Intent {
            return BaseSettingsActivity.createIntent(context, R.xml.settings_pref, YourSettingsActivity::class.java)
        }
    }
}

4. Create the preferences XML

Create src/main/res/xml/settings_pref.xml:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <PreferenceCategory android:title="Your Settings">
        <club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
            app:customHint="Hint text when empty"
            android:inputType="text"
            android:key="your_setting_key"
            android:summary="%s"
            android:title="Setting Title" />
    </PreferenceCategory>

</PreferenceScreen>

5. Declare the settings activity in AndroidManifest.xml

<activity
    android:name=".YourSettingsActivity"
    android:exported="false"
    android:label="Settings"
    android:parentActivityName=".YourActivity"
    android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
    <meta-data
        android:name="android.support.PARENT_ACTIVITY"
        android:value=".YourActivity"/>
</activity>

6. Launch settings from your activity

// In Java:
Intent intent = YourSettingsActivity.createIntent(this);
startActivity(intent);
// In Kotlin:
startActivity(YourSettingsActivity.createIntent(this))

Using MQTT

MQTT connection settings (server URI, username, password, battlefield) are configured centrally in the "MQTT Settings" page accessible from the main launcher's overflow menu. Subapps do not need their own MQTT settings UI.

A single SharedMqttClient singleton (in shared-services) manages the MQTT connection for the entire app. It connects at app startup and reconnects automatically when settings change. Subapps publish and subscribe through it:

import club.clubk.ktag.apps.sharedservices.SharedMqttClient
import club.clubk.ktag.apps.sharedservices.MqttMessageListener

// Publish a message
val battlefield = SharedMqttClient.battlefield
SharedMqttClient.publish("KTag/$battlefield/YourApp/Hello", "Hello!".toByteArray(), qos = 1, retained = false)

// Subscribe to a topic
val listener = object : MqttMessageListener {
    override fun onMessageReceived(topic: String, payload: ByteArray) {
        val text = String(payload)
        // handle message
    }
}
SharedMqttClient.subscribe("KTag/$battlefield/YourApp/Listen", listener)

// Unsubscribe when done (e.g. in cleanup/onCleared)
SharedMqttClient.unsubscribe("KTag/$battlefield/YourApp/Listen", listener)

GPS Location Publishing

Once connected to an MQTT broker, the app automatically publishes the device's GPS position every 30 seconds (subject to OS scheduling) to:

KTag/{Battlefield}/Devices/{Device ID}/Location

The payload is a JSON object:

{"lat":51.5074,"lon":-0.1278,"alt":12.3,"accuracy":5.0}

alt and accuracy are omitted if the device does not provide them. The message is published with QoS 1 and retained=true, so a subscriber joining mid-game will immediately receive the last known position. Location updates stop automatically when the MQTT connection is lost.

The MQTT Settings page includes a Device ID field (defaults to the device's model name) used as the MQTT client identifier. When Autodiscovery is enabled, the client automatically finds a KTag broker on the local network via mDNS (_mqtt._tcp. with purpose=KTag MQTT Broker) and connects using the credentials KTag / {Battlefield}. The broker automatically ensures this credential exists on startup.

License: AGPL-3.0-or-later

This software is part of the KTag project, a DIY laser tag game with customizable features and wide interoperability.

🛡 https://ktag.clubk.club 🃞

Copyright © 2025-2026 Joseph P. Kearney and the KTag developers.

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.

There should be a copy of the GNU Affero General Public License in the LICENSE file in the root of this repository. If not, see http://www.gnu.org/licenses/.

Open-Source Software

This software in turn makes use of the following open-source software libraries and components:

Name Version License (SPDX) URL
Kotlin 2.2.20 Apache-2.0 https://kotlinlang.org/
JetBrains Mono 2.304 OFL-1.1 https://github.com/JetBrains/JetBrainsMono
Moquette MQTT Broker 0.17 Apache-2.0 https://github.com/moquette-io/moquette
Eclipse Paho MQTT 1.2.4 + 1.1.1 EPL-2.0 https://github.com/eclipse/paho.mqtt.java
usb-serial-for-android 3.10.0 MIT https://github.com/mik3y/usb-serial-for-android/
CameraX 1.4.0 Apache-2.0 https://developer.android.com/jetpack/androidx/releases/camera
MediaPipe Tasks 0.10.32 Apache-2.0 https://developers.google.com/mediapipe
EfficientDet-Lite0 1 Apache-2.0 https://tfhub.dev/tensorflow/lite-model/efficientdet/lite0/detection/metadata/1