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,58 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.mqttbroker"
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += listOf(
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties",
"META-INF/DEPENDENCIES"
)
}
}
}
dependencies {
implementation(project(":core"))
implementation(project(":shared-services"))
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("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.preference:preference:1.2.1")
// Moquette MQTT Broker
implementation("io.moquette:moquette-broker:0.17")
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".MqttBrokerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".BrokerSettingsActivity"
android:exported="false"
android:parentActivityName="club.clubk.ktag.apps.MainActivity"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar" />
</application>
</manifest>

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.mqttbroker
object BrokerPreferenceKeys {
const val BROKER_ENABLED = "broker_enabled"
const val SSL_ENABLED = "broker_ssl_enabled"
const val AUTH_ENABLED = "broker_auth_enabled"
const val NSD_ENABLED = "broker_nsd_enabled"
const val PORT = "broker_port"
const val SSL_PORT = "broker_ssl_port"
const val DEFAULT_PORT = "1883"
const val DEFAULT_SSL_PORT = "8883"
}

View file

@ -0,0 +1,48 @@
package club.clubk.ktag.apps.mqttbroker
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import club.clubk.ktag.apps.sharedservices.SharedMqttClient
class BrokerSettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(club.clubk.ktag.apps.sharedservices.R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(club.clubk.ktag.apps.sharedservices.R.id.settings_container, BrokerSettingsFragment())
.commit()
}
}
override fun finish() {
if (MqttBrokerManager.isBrokerEnabled(this)) {
MqttBrokerManager.startBrokerIfEnabled(this)
} else {
MqttBrokerManager.stopBroker(this)
}
SharedMqttClient.connect(this)
super.finish()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
fun createIntent(context: Context): Intent {
return Intent(context, BrokerSettingsActivity::class.java)
}
}
}

View file

@ -0,0 +1,130 @@
package club.clubk.ktag.apps.mqttbroker
import android.os.Bundle
import android.text.InputType
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import club.clubk.ktag.apps.sharedservices.MqttPreferenceKeys
class BrokerSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.broker_settings_pref, rootKey)
findPreference<Preference>("broker_manage_users")?.setOnPreferenceClickListener {
showManageUsersDialog()
true
}
// Set up autodiscovery toggle to enable/disable manual connection fields
val autodiscoveryPref = findPreference<SwitchPreferenceCompat>(MqttPreferenceKeys.AUTODISCOVERY)
autodiscoveryPref?.setOnPreferenceChangeListener { _, newValue ->
updateManualFieldsEnabled(newValue as Boolean)
true
}
updateManualFieldsEnabled(autodiscoveryPref?.isChecked ?: false)
updateManageUsersSummary()
}
private fun updateManualFieldsEnabled(autodiscoveryEnabled: Boolean) {
val disabled = autodiscoveryEnabled
findPreference<Preference>(MqttPreferenceKeys.SERVER_URI)?.isEnabled = !disabled
findPreference<Preference>(MqttPreferenceKeys.USERNAME)?.isEnabled = !disabled
findPreference<Preference>(MqttPreferenceKeys.PASSWORD)?.isEnabled = !disabled
}
private fun updateManageUsersSummary() {
val count = CredentialManager.getCredentialCount(requireContext())
findPreference<Preference>("broker_manage_users")?.summary =
if (count == 0) "No users configured"
else "$count user(s) configured"
}
private fun showManageUsersDialog() {
val context = requireContext()
val credentials = CredentialManager.loadCredentials(context)
val usernames = credentials.keys.toList()
val builder = AlertDialog.Builder(context)
.setTitle("Manage Users")
.setPositiveButton("Add User") { _, _ -> showAddUserDialog() }
.setNegativeButton("Close", null)
if (usernames.isEmpty()) {
builder.setMessage("No users configured. Add a user to enable authentication.")
} else {
builder.setItems(usernames.toTypedArray()) { _, which ->
showRemoveUserDialog(usernames[which])
}
}
builder.show()
}
private fun showAddUserDialog() {
val context = requireContext()
val layout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, 0)
}
val usernameInput = EditText(context).apply {
hint = "Username"
inputType = InputType.TYPE_CLASS_TEXT
}
layout.addView(usernameInput)
val passwordInput = EditText(context).apply {
hint = "Password"
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
layout.addView(passwordInput)
AlertDialog.Builder(context)
.setTitle("Add User")
.setView(layout)
.setPositiveButton("Add") { _, _ ->
val username = usernameInput.text.toString().trim()
val password = passwordInput.text.toString()
when {
username.isEmpty() || password.isEmpty() -> {
Toast.makeText(context, "Username and password are required", Toast.LENGTH_SHORT).show()
}
username.contains(":") -> {
Toast.makeText(context, "Username cannot contain ':'", Toast.LENGTH_SHORT).show()
}
else -> {
CredentialManager.addCredential(context, username, password)
Toast.makeText(context, "User '$username' added", Toast.LENGTH_SHORT).show()
updateManageUsersSummary()
}
}
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showRemoveUserDialog(username: String) {
val context = requireContext()
AlertDialog.Builder(context)
.setTitle("Remove User")
.setMessage("Remove user '$username'?")
.setPositiveButton("Remove") { _, _ ->
CredentialManager.removeCredential(context, username)
Toast.makeText(context, "User '$username' removed", Toast.LENGTH_SHORT).show()
updateManageUsersSummary()
}
.setNegativeButton("Cancel", null)
.show()
}
}

View file

@ -0,0 +1,101 @@
package club.clubk.ktag.apps.mqttbroker;
import android.content.Context;
import android.util.Log;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedHashMap;
import java.util.Map;
public class CredentialManager {
private static final String TAG = "CredentialManager";
private static final String CREDENTIALS_FILE = "mqtt_credentials.txt";
public static Map<String, String> loadCredentials(Context context) {
Map<String, String> credentials = new LinkedHashMap<>();
File file = new File(context.getFilesDir(), CREDENTIALS_FILE);
if (!file.exists()) {
return credentials;
}
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
int sep = line.indexOf(':');
if (sep > 0 && sep < line.length() - 1) {
String username = line.substring(0, sep);
String hash = line.substring(sep + 1);
credentials.put(username, hash);
}
}
} catch (IOException e) {
Log.e(TAG, "Error loading credentials", e);
}
return credentials;
}
public static void saveCredentials(Context context, Map<String, String> credentials) {
File file = new File(context.getFilesDir(), CREDENTIALS_FILE);
try (BufferedWriter writer = new BufferedWriter(new FileWriter(file))) {
for (Map.Entry<String, String> entry : credentials.entrySet()) {
writer.write(entry.getKey() + ":" + entry.getValue());
writer.newLine();
}
} catch (IOException e) {
Log.e(TAG, "Error saving credentials", e);
}
}
public static void addCredential(Context context, String username, String password) {
Map<String, String> credentials = loadCredentials(context);
credentials.put(username, hashPassword(password));
saveCredentials(context, credentials);
}
public static void removeCredential(Context context, String username) {
Map<String, String> credentials = loadCredentials(context);
credentials.remove(username);
saveCredentials(context, credentials);
}
public static int getCredentialCount(Context context) {
return loadCredentials(context).size();
}
public static boolean checkCredential(String username, byte[] password, Map<String, String> credentials) {
if (username == null || password == null || credentials == null) {
return false;
}
String storedHash = credentials.get(username);
if (storedHash == null) {
return false;
}
String providedHash = hashPassword(new String(password, StandardCharsets.UTF_8));
return storedHash.equals(providedHash);
}
private static String hashPassword(String password) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
}

View file

@ -0,0 +1,21 @@
package club.clubk.ktag.apps.mqttbroker;
import android.content.Context;
import java.util.Map;
import io.moquette.broker.security.IAuthenticator;
public class FileAuthenticator implements IAuthenticator {
private final Context context;
public FileAuthenticator(Context context) {
this.context = context.getApplicationContext();
}
@Override
public boolean checkValid(String clientId, String username, byte[] password) {
Map<String, String> credentials = CredentialManager.loadCredentials(context);
return CredentialManager.checkCredential(username, password, credentials);
}
}

View file

@ -0,0 +1,63 @@
package club.clubk.ktag.apps.mqttbroker
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.text.format.Formatter
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed class BrokerStatus {
data object Stopped : BrokerStatus()
data object Starting : BrokerStatus()
data class Running(val port: Int, val sslPort: Int? = null) : BrokerStatus()
data class Error(val message: String) : BrokerStatus()
}
object MqttBrokerManager {
private val _status = MutableStateFlow<BrokerStatus>(BrokerStatus.Stopped)
val status: StateFlow<BrokerStatus> = _status.asStateFlow()
@JvmStatic
fun updateStatus(status: BrokerStatus) {
_status.value = status
}
fun isBrokerEnabled(context: Context): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return prefs.getBoolean(BrokerPreferenceKeys.BROKER_ENABLED, false)
}
fun startBroker(context: Context) {
val intent = Intent(context, MqttBrokerService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stopBroker(context: Context) {
val intent = Intent(context, MqttBrokerService::class.java)
context.stopService(intent)
}
fun startBrokerIfEnabled(context: Context) {
if (isBrokerEnabled(context)) {
startBroker(context)
}
}
@Suppress("DEPRECATION")
fun getDeviceIpAddress(context: Context): String? {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
?: return null
val ipInt = wifiManager.connectionInfo.ipAddress
if (ipInt == 0) return null
return Formatter.formatIpAddress(ipInt)
}
}

View file

@ -0,0 +1,326 @@
package club.clubk.ktag.apps.mqttbroker;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.widget.Toast;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.preference.PreferenceManager;
import club.clubk.ktag.apps.sharedservices.MqttPreferenceKeys;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import io.moquette.broker.Server;
import io.moquette.broker.config.MemoryConfig;
import io.moquette.interception.AbstractInterceptHandler;
import io.moquette.interception.InterceptHandler;
import io.moquette.interception.messages.InterceptConnectMessage;
import io.moquette.interception.messages.InterceptDisconnectMessage;
import io.moquette.interception.messages.InterceptPublishMessage;
import io.moquette.interception.messages.InterceptSubscribeMessage;
import io.moquette.interception.messages.InterceptUnsubscribeMessage;
public class MqttBrokerService extends Service {
private static final String TAG = "MqttBrokerService";
private static final String CHANNEL_ID = "MqttBrokerChannel";
private static final int NOTIFICATION_ID = 1;
private Server mqttBroker;
private int port = 1883;
private int sslPort = 8883;
private boolean sslEnabled = false;
private boolean authEnabled = false;
private boolean nsdEnabled = false;
private NsdManager nsdManager;
private NsdManager.RegistrationListener nsdRegistrationListener;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
Log.d(TAG, "Service created");
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
stopBroker();
loadConfiguration();
startForeground(NOTIFICATION_ID, createNotification("MQTT Broker is starting..."));
startBroker();
return START_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void loadConfiguration() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
sslEnabled = prefs.getBoolean(BrokerPreferenceKeys.SSL_ENABLED, false);
authEnabled = prefs.getBoolean(BrokerPreferenceKeys.AUTH_ENABLED, false);
nsdEnabled = prefs.getBoolean(BrokerPreferenceKeys.NSD_ENABLED, false);
port = Integer.parseInt(prefs.getString(BrokerPreferenceKeys.PORT, "1883"));
sslPort = Integer.parseInt(prefs.getString(BrokerPreferenceKeys.SSL_PORT, "8883"));
}
public boolean isBrokerRunning() {
return mqttBroker != null;
}
private void startBroker() {
MqttBrokerManager.updateStatus(BrokerStatus.Starting.INSTANCE);
new Thread(() -> {
try {
mqttBroker = new Server();
Properties configProps = new Properties();
configProps.setProperty("host", "0.0.0.0");
configProps.setProperty("port", String.valueOf(port));
configProps.setProperty("allow_anonymous", String.valueOf(!authEnabled));
configProps.setProperty("allow_zero_byte_client_id", "true");
configProps.setProperty("reauthorize_subscriptions_on_connect", "false");
// SSL Configuration
if (sslEnabled) {
if (!SSLCertificateManager.keystoreExists(getApplicationContext())) {
Log.d(TAG, "Keystore not found, generating self-signed certificate...");
SSLCertificateManager.generateSelfSignedCertificate(getApplicationContext());
}
configProps.setProperty("ssl_port", String.valueOf(sslPort));
configProps.setProperty("jks_path",
SSLCertificateManager.getKeystoreFile(getApplicationContext()).getAbsolutePath());
configProps.setProperty("key_store_type", "pkcs12");
configProps.setProperty("key_store_password", SSLCertificateManager.getKeystorePassword());
configProps.setProperty("key_manager_password", SSLCertificateManager.getKeystorePassword());
Log.d(TAG, "SSL enabled on port " + sslPort);
}
// Persistent data path
File dataDir = new File(getFilesDir(), "moquette_data");
if (!dataDir.exists()) {
dataDir.mkdirs();
}
configProps.setProperty("persistent_store", dataDir.getAbsolutePath());
MemoryConfig memoryConfig = new MemoryConfig(configProps);
List<InterceptHandler> handlers = new ArrayList<>();
handlers.add(new BrokerInterceptor());
// Ensure KTag autodiscovery credential exists
ensureKTagCredential();
if (authEnabled) {
FileAuthenticator authenticator = new FileAuthenticator(getApplicationContext());
mqttBroker.startServer(memoryConfig, handlers, null, authenticator, null);
} else {
mqttBroker.startServer(memoryConfig, handlers);
}
String statusMsg = "Broker running on port " + port;
if (sslEnabled) {
statusMsg += " (SSL: " + sslPort + ")";
}
if (authEnabled) {
statusMsg += " (Auth)";
}
if (nsdEnabled) {
statusMsg += " (mDNS)";
}
Log.d(TAG, "MQTT Broker started - " + statusMsg);
updateNotification(statusMsg);
MqttBrokerManager.updateStatus(new BrokerStatus.Running(port,
sslEnabled ? Integer.valueOf(sslPort) : null));
if (nsdEnabled) {
registerNsdService();
}
} catch (Exception e) {
Log.e(TAG, "Failed to start broker", e);
updateNotification("Failed to start: " + e.getMessage());
MqttBrokerManager.updateStatus(new BrokerStatus.Error(
e.getMessage() != null ? e.getMessage() : "Unknown error"));
}
}).start();
}
private void ensureKTagCredential() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String battlefield = prefs.getString(
MqttPreferenceKeys.BATTLEFIELD,
MqttPreferenceKeys.DEFAULT_BATTLEFIELD);
CredentialManager.addCredential(getApplicationContext(), "KTag", battlefield);
Log.d(TAG, "Ensured KTag credential with battlefield: " + battlefield);
}
private void stopBroker() {
unregisterNsdService();
if (mqttBroker != null) {
mqttBroker.stopServer();
mqttBroker = null;
}
MqttBrokerManager.updateStatus(BrokerStatus.Stopped.INSTANCE);
}
private void registerNsdService() {
NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName("MQTT Broker - KTag");
serviceInfo.setServiceType("_mqtt._tcp.");
serviceInfo.setPort(port);
serviceInfo.setAttribute("purpose", "KTag MQTT Broker");
serviceInfo.setAttribute("version", "1");
serviceInfo.setAttribute("ssl", String.valueOf(sslEnabled));
serviceInfo.setAttribute("auth", String.valueOf(authEnabled));
nsdManager = (NsdManager) getSystemService(Context.NSD_SERVICE);
nsdRegistrationListener = new NsdManager.RegistrationListener() {
@Override
public void onServiceRegistered(NsdServiceInfo info) {
Log.d(TAG, "NSD service registered: " + info.getServiceName());
}
@Override
public void onRegistrationFailed(NsdServiceInfo info, int errorCode) {
Log.e(TAG, "NSD registration failed: " + errorCode);
}
@Override
public void onServiceUnregistered(NsdServiceInfo info) {
Log.d(TAG, "NSD service unregistered");
}
@Override
public void onUnregistrationFailed(NsdServiceInfo info, int errorCode) {
Log.e(TAG, "NSD unregistration failed: " + errorCode);
}
};
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, nsdRegistrationListener);
}
private void unregisterNsdService() {
if (nsdManager != null && nsdRegistrationListener != null) {
try {
nsdManager.unregisterService(nsdRegistrationListener);
} catch (IllegalArgumentException e) {
Log.w(TAG, "NSD service already unregistered", e);
}
nsdRegistrationListener = null;
nsdManager = null;
}
}
@Override
public void onDestroy() {
super.onDestroy();
new Thread(this::stopBroker).start();
Log.d(TAG, "Service destroyed");
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"MQTT Broker Service",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("MQTT Broker background service");
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
}
private Notification createNotification(String contentText) {
Intent notificationIntent;
try {
notificationIntent = new Intent(this,
Class.forName("club.clubk.ktag.apps.MainActivity"));
} catch (ClassNotFoundException e) {
notificationIntent = new Intent();
}
PendingIntent pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("MQTT Broker")
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build();
}
private void updateNotification(String contentText) {
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.notify(NOTIFICATION_ID, createNotification(contentText));
}
}
private class BrokerInterceptor extends AbstractInterceptHandler {
@Override
public String getID() {
return "BrokerMonitor";
}
@Override
public void onConnect(InterceptConnectMessage msg) {
Log.d(TAG, "Client connected: " + msg.getClientID());
new Handler(Looper.getMainLooper()).post(() ->
Toast.makeText(MqttBrokerService.this,
"MQTT: " + msg.getClientID() + " connected",
Toast.LENGTH_SHORT).show());
}
@Override
public void onDisconnect(InterceptDisconnectMessage msg) {
Log.d(TAG, "Client disconnected: " + msg.getClientID());
}
@Override
public void onPublish(InterceptPublishMessage msg) {
Log.d(TAG, "Message published to " + msg.getTopicName() + " by " + msg.getClientID());
}
@Override
public void onSubscribe(InterceptSubscribeMessage msg) {
Log.d(TAG, msg.getClientID() + " subscribed to " + msg.getTopicFilter());
}
@Override
public void onUnsubscribe(InterceptUnsubscribeMessage msg) {
Log.d(TAG, msg.getClientID() + " unsubscribed from " + msg.getTopicFilter());
}
@Override
public void onSessionLoopError(Throwable ex) {
Log.e(TAG, "Session loop error", ex);
}
}
}

View file

@ -0,0 +1,317 @@
package club.clubk.ktag.apps.mqttbroker;
import android.content.Context;
import android.util.Log;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Date;
public class SSLCertificateManager {
private static final String TAG = "SSLCertManager";
private static final String KEYSTORE_FILE = "mqtt_broker.p12";
private static final String KEYSTORE_PASSWORD = "mqtt_broker_password";
private static final String KEY_ALIAS = "mqtt_broker_key";
public static File getKeystoreFile(Context context) {
return new File(context.getFilesDir(), KEYSTORE_FILE);
}
public static String getKeystorePassword() {
return KEYSTORE_PASSWORD;
}
public static String getKeyAlias() {
return KEY_ALIAS;
}
public static boolean keystoreExists(Context context) {
return getKeystoreFile(context).exists();
}
public static void generateSelfSignedCertificate(Context context) throws Exception {
Log.d(TAG, "Generating self-signed certificate...");
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048, new SecureRandom());
KeyPair keyPair = keyGen.generateKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
long now = System.currentTimeMillis();
Date notBefore = new Date(now - 24 * 60 * 60 * 1000);
Date notAfter = new Date(now + 365L * 24 * 60 * 60 * 1000);
BigInteger serial = BigInteger.valueOf(now);
String dn = "CN=MQTT Broker, O=KTag Apps, C=US";
byte[] certDer = buildSelfSignedCertificateDer(
keyPair, privateKey, serial, notBefore, notAfter, dn);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(
new ByteArrayInputStream(certDer));
cert.verify(keyPair.getPublic());
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
Certificate[] chain = new Certificate[]{cert};
keyStore.setKeyEntry(KEY_ALIAS, privateKey, KEYSTORE_PASSWORD.toCharArray(), chain);
File keystoreFile = getKeystoreFile(context);
try (FileOutputStream fos = new FileOutputStream(keystoreFile)) {
keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray());
}
Log.d(TAG, "Self-signed certificate generated and saved to " + keystoreFile.getAbsolutePath());
}
public static KeyStore loadKeystore(Context context) throws Exception {
File keystoreFile = getKeystoreFile(context);
if (!keystoreFile.exists()) {
throw new Exception("Keystore file does not exist");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keystoreFile)) {
keyStore.load(fis, KEYSTORE_PASSWORD.toCharArray());
}
return keyStore;
}
public static void deleteKeystore(Context context) {
File keystoreFile = getKeystoreFile(context);
if (keystoreFile.exists()) {
if (keystoreFile.delete()) {
Log.d(TAG, "Keystore deleted");
} else {
Log.e(TAG, "Failed to delete keystore");
}
}
}
public static String getCertificateInfo(Context context) {
try {
KeyStore keyStore = loadKeystore(context);
X509Certificate cert = (X509Certificate) keyStore.getCertificate(KEY_ALIAS);
if (cert != null) {
StringBuilder info = new StringBuilder();
info.append("Subject: ").append(cert.getSubjectDN().getName()).append("\n");
info.append("Issuer: ").append(cert.getIssuerDN().getName()).append("\n");
info.append("Valid from: ").append(cert.getNotBefore()).append("\n");
info.append("Valid until: ").append(cert.getNotAfter()).append("\n");
info.append("Serial: ").append(cert.getSerialNumber()).append("\n");
return info.toString();
}
} catch (Exception e) {
Log.e(TAG, "Error getting certificate info", e);
}
return "Certificate info not available";
}
// -----------------------------------------------------------------------
// DER / ASN.1 certificate builder using only java.security APIs
// -----------------------------------------------------------------------
private static byte[] buildSelfSignedCertificateDer(
KeyPair keyPair, PrivateKey privateKey,
BigInteger serial, Date notBefore, Date notAfter,
String distinguishedName) throws Exception {
byte[] tbsCert = buildTbsCertificate(
serial, notBefore, notAfter, distinguishedName,
keyPair.getPublic().getEncoded());
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(tbsCert);
byte[] signature = sig.sign();
byte[] signatureAlgorithm = derSequence(new byte[][]{
derOid(new int[]{1, 2, 840, 113549, 1, 1, 11}),
derNull()
});
byte[] signatureBits = new byte[signature.length + 1];
signatureBits[0] = 0x00;
System.arraycopy(signature, 0, signatureBits, 1, signature.length);
byte[] signatureBitString = derBitString(signatureBits);
return derSequence(new byte[][]{tbsCert, signatureAlgorithm, signatureBitString});
}
private static byte[] buildTbsCertificate(
BigInteger serial, Date notBefore, Date notAfter,
String distinguishedName, byte[] publicKeyEncoded) throws Exception {
byte[] version = derExplicitTag(0, derInteger(BigInteger.valueOf(2)));
byte[] serialNumber = derInteger(serial);
byte[] signatureAlgorithm = derSequence(new byte[][]{
derOid(new int[]{1, 2, 840, 113549, 1, 1, 11}),
derNull()
});
byte[] issuer = encodeDN(distinguishedName);
byte[] subject = issuer;
byte[] validity = derSequence(new byte[][]{
derUtcTime(notBefore),
derUtcTime(notAfter)
});
byte[] subjectPublicKeyInfo = publicKeyEncoded;
return derSequence(new byte[][]{
version, serialNumber, signatureAlgorithm,
issuer, validity, subject, subjectPublicKeyInfo
});
}
private static byte[] encodeDN(String dn) {
String[] parts = dn.split(",");
byte[][] rdnSets = new byte[parts.length][];
for (int i = 0; i < parts.length; i++) {
String part = parts[i].trim();
int eq = part.indexOf('=');
String key = part.substring(0, eq).trim();
String value = part.substring(eq + 1).trim();
rdnSets[i] = derSet(new byte[][]{
derSequence(new byte[][]{
derOidForAttributeType(key),
derUtf8String(value)
})
});
}
return derSequence(rdnSets);
}
private static byte[] derOidForAttributeType(String type) {
switch (type.toUpperCase()) {
case "CN": return derOid(new int[]{2, 5, 4, 3});
case "O": return derOid(new int[]{2, 5, 4, 10});
case "C": return derOid(new int[]{2, 5, 4, 6});
case "OU": return derOid(new int[]{2, 5, 4, 11});
case "ST": return derOid(new int[]{2, 5, 4, 8});
case "L": return derOid(new int[]{2, 5, 4, 7});
default:
throw new IllegalArgumentException("Unsupported attribute type: " + type);
}
}
private static byte[] derSequence(byte[][] items) {
return derConstructed(0x30, items);
}
private static byte[] derSet(byte[][] items) {
return derConstructed(0x31, items);
}
private static byte[] derConstructed(int tag, byte[][] items) {
int totalLen = 0;
for (byte[] item : items) totalLen += item.length;
byte[] content = new byte[totalLen];
int offset = 0;
for (byte[] item : items) {
System.arraycopy(item, 0, content, offset, item.length);
offset += item.length;
}
return derWrap(tag, content);
}
private static byte[] derExplicitTag(int tagNumber, byte[] content) {
return derWrap(0xA0 | tagNumber, content);
}
private static byte[] derInteger(BigInteger value) {
byte[] encoded = value.toByteArray();
return derWrap(0x02, encoded);
}
private static byte[] derBitString(byte[] content) {
return derWrap(0x03, content);
}
private static byte[] derNull() {
return new byte[]{0x05, 0x00};
}
private static byte[] derUtf8String(String s) {
byte[] content = s.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return derWrap(0x0C, content);
}
@SuppressWarnings("deprecation")
private static byte[] derUtcTime(Date date) {
java.util.Calendar cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"));
cal.setTime(date);
String s = String.format(java.util.Locale.US, "%02d%02d%02d%02d%02d%02dZ",
cal.get(java.util.Calendar.YEAR) % 100,
cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH),
cal.get(java.util.Calendar.HOUR_OF_DAY),
cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND));
return derWrap(0x17, s.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
private static byte[] derOid(int[] components) {
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
buf.write(40 * components[0] + components[1]);
for (int i = 2; i < components.length; i++) {
encodeOidComponent(buf, components[i]);
}
return derWrap(0x06, buf.toByteArray());
}
private static void encodeOidComponent(java.io.ByteArrayOutputStream buf, int value) {
if (value < 128) {
buf.write(value);
} else {
byte[] temp = new byte[5];
int pos = temp.length;
temp[--pos] = (byte) (value & 0x7F);
value >>= 7;
while (value > 0) {
temp[--pos] = (byte) (0x80 | (value & 0x7F));
value >>= 7;
}
buf.write(temp, pos, temp.length - pos);
}
}
private static byte[] derWrap(int tag, byte[] content) {
byte[] lenBytes = derLength(content.length);
byte[] result = new byte[1 + lenBytes.length + content.length];
result[0] = (byte) tag;
System.arraycopy(lenBytes, 0, result, 1, lenBytes.length);
System.arraycopy(content, 0, result, 1 + lenBytes.length, content.length);
return result;
}
private static byte[] derLength(int length) {
if (length < 128) {
return new byte[]{(byte) length};
} else if (length < 256) {
return new byte[]{(byte) 0x81, (byte) length};
} else if (length < 65536) {
return new byte[]{(byte) 0x82, (byte) (length >> 8), (byte) length};
} else {
return new byte[]{(byte) 0x83, (byte) (length >> 16), (byte) (length >> 8), (byte) length};
}
}
}

View file

@ -0,0 +1,111 @@
<?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="Client Connection">
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="A name for this device"
android:inputType="text"
android:key="mqtt_device_id"
android:summary="%s"
android:title="Device ID" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Your assigned section of the MQTT server"
android:inputType="text"
android:key="mqtt_battlefield"
android:summary="%s"
android:title="KTag Battlefield" />
<SwitchPreferenceCompat
android:key="mqtt_autodiscovery"
android:title="Autodiscovery"
android:summary="Automatically find a broker on the local network"
android:defaultValue="true" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="ssl://mqtt.clubk.club:8883"
android:inputType="textUri"
android:key="mqtt_server_uri"
android:summary="%s"
android:title="KTag Server" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Enter your MQTT username"
android:inputType="text"
android:key="mqtt_username"
android:summary="%s"
android:title="Username" />
<club.clubk.ktag.apps.sharedservices.SummarizedEditTextPreference
app:customHint="Enter your MQTT password"
android:inputType="textPassword"
android:key="mqtt_password"
android:summary="shh...it's a secret!"
android:title="Password" />
</PreferenceCategory>
<PreferenceCategory android:title="Broker">
<SwitchPreferenceCompat
android:key="broker_enabled"
android:title="Enable MQTT Broker"
android:summary="Run an embedded MQTT broker on this device"
android:defaultValue="false" />
</PreferenceCategory>
<PreferenceCategory android:title="Network">
<EditTextPreference
android:key="broker_port"
android:title="MQTT Port"
android:summary="Default: 1883"
android:defaultValue="1883"
android:inputType="number" />
<EditTextPreference
android:key="broker_ssl_port"
android:title="SSL Port"
android:summary="Default: 8883"
android:defaultValue="8883"
android:inputType="number"
android:dependency="broker_ssl_enabled" />
</PreferenceCategory>
<PreferenceCategory android:title="Security">
<SwitchPreferenceCompat
android:key="broker_ssl_enabled"
android:title="Enable SSL/TLS"
android:summary="Use a self-signed certificate for encrypted connections"
android:defaultValue="false" />
<SwitchPreferenceCompat
android:key="broker_auth_enabled"
android:title="Enable Authentication"
android:summary="Require username/password to connect"
android:defaultValue="false" />
<Preference
android:key="broker_manage_users"
android:title="Manage Users"
android:summary="Add or remove user credentials"
android:dependency="broker_auth_enabled" />
</PreferenceCategory>
<PreferenceCategory android:title="Discovery">
<SwitchPreferenceCompat
android:key="broker_nsd_enabled"
android:title="Enable mDNS Discovery"
android:summary="Advertise this broker on the local network"
android:defaultValue="false" />
</PreferenceCategory>
</PreferenceScreen>