← Wazuh Mobile Agent
Source

Every line, as shipped

The complete Kotlin source for the Wazuh Mobile Agent — the enrollment protocol, the collectors, the YARA engine, and the AI Analysis screen — plus its build configuration. Nothing trimmed for the page except one throwaway lab keystore password.

org.wazuh.androidagent 36 files ~168 KB of source Kotlin, JUnit, Gradle KTS
Click any file to expand it. Files are grouped by what they actually do, not alphabetically — start with "Wazuh agent protocol" if you want to see the real enrollment/transport handshake, or "Collectors" for what the app actually gathers.

App core

Application entry point, the always-on foreground service, and settings storage.

app/src/main/java/org/wazuh/androidagent/AgentApplication.kt 12 lines
package org.wazuh.androidagent

import android.app.Application
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.util.CrashCatcher

class AgentApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        CrashCatcher.install(LogQueue(this))
    }
}
app/src/main/java/org/wazuh/androidagent/AgentForegroundService.kt 321 lines
package org.wazuh.androidagent

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.wazuh.androidagent.collectors.BatteryReceiver
import org.wazuh.androidagent.collectors.DeviceInfoCollector
import org.wazuh.androidagent.collectors.InstalledAppsCollector
import org.wazuh.androidagent.collectors.NetworkStateReceiver
import org.wazuh.androidagent.collectors.SystemLogCollector
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.net.Severity
import org.wazuh.androidagent.net.SyslogConfig
import org.wazuh.androidagent.net.SyslogSender
import org.wazuh.androidagent.wazuhagent.KeepAliveBuilder
import org.wazuh.androidagent.wazuhagent.WazuhAgentConnection
import org.wazuh.androidagent.yara.YaraScanner
import java.time.Instant

/**
 * The agent's core: a visible, persistent foreground service (Android requires
 * this to be shown to the user — there is no hidden/background-only mode) that
 * runs the collectors and keeps a syslog connection to the configured Wazuh
 * manager open, draining the local queue as connectivity allows.
 */
class AgentForegroundService : Service() {

    private val binder = LocalBinder()
    private lateinit var queue: LogQueue
    private lateinit var settings: SettingsStore
    private var syslogSender: SyslogSender? = null
    private var agentConnection: WazuhAgentConnection? = null
    private lateinit var installedAppsCollector: InstalledAppsCollector
    private lateinit var yaraScanner: YaraScanner

    private var handlerThread: HandlerThread? = null
    private var handler: Handler? = null

    private var networkReceiver: NetworkStateReceiver? = null
    private var batteryReceiver: BatteryReceiver? = null

    @Volatile var lastConnected: Boolean = false
        private set
    @Volatile var lastError: String? = null
        private set

    inner class LocalBinder : Binder() {
        fun getService(): AgentForegroundService = this@AgentForegroundService
    }

    override fun onBind(intent: Intent?): IBinder = binder

    override fun onCreate() {
        super.onCreate()
        queue = LogQueue(applicationContext)
        settings = SettingsStore(applicationContext)
        installedAppsCollector = InstalledAppsCollector(applicationContext)
        yaraScanner = YaraScanner(applicationContext)

        createNotificationChannel()
        startForeground(NOTIFICATION_ID, buildNotification("Starting…"))

        startTransport()

        registerReceivers()
        startPeriodicCollectors()

        queue.enqueue(
            LogEvent(
                timestamp = Instant.now(),
                msgId = "agent_started",
                severity = Severity.NOTICE,
                fields = mapOf("version" to BuildConfigShim.VERSION_NAME)
            )
        )
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return START_STICKY
    }

    override fun onDestroy() {
        queue.enqueue(
            LogEvent(
                timestamp = Instant.now(),
                msgId = "agent_stopped",
                severity = Severity.NOTICE,
                fields = emptyMap()
            )
        )
        syslogSender?.stop()
        agentConnection?.stop()
        unregisterReceivers()
        handlerThread?.quitSafely()
        super.onDestroy()
    }

    /** Call after Settings change so the active transport reconnects with the new destination. */
    fun reloadConfig() {
        syslogSender?.updateConfig(currentSyslogConfig())
        // The agent-protocol connection's identity/host rarely changes without re-enrolling;
        // a full service restart (stop/start) is the simple, reliable way to pick that up.
    }

    fun enqueueTestEvent() {
        queue.enqueue(
            LogEvent(
                timestamp = Instant.now(),
                msgId = "test_event",
                severity = Severity.INFO,
                fields = mapOf("message" to "Manual test event from Wazuh Android Agent")
            )
        )
    }

    fun queueSize(): Int = queue.size()
    fun sentCount(): Long = syslogSender?.sentCount ?: agentConnection?.sentCount ?: 0
    fun isConnected(): Boolean = syslogSender?.isConnected() ?: agentConnection?.isConnected() ?: false

    private fun deviceLabel(): String = settings.deviceLabel.ifBlank {
        android.provider.Settings.Secure.getString(contentResolver, android.provider.Settings.Secure.ANDROID_ID)
            ?: "android-device"
    }

    private fun startTransport() {
        when (settings.transportMode) {
            TransportMode.SYSLOG -> {
                val s = SyslogSender(queue, currentSyslogConfig())
                s.onStateChange = { connected, error ->
                    lastConnected = connected
                    lastError = error
                    updateNotification()
                }
                s.start()
                syslogSender = s
            }
            TransportMode.WAZUH_AGENT -> {
                val identity = settings.agentIdentity()
                if (identity == null) {
                    lastConnected = false
                    lastError = "Not enrolled - enroll in the app first"
                    updateNotification()
                    return
                }
                val label = deviceLabel()
                val c = WazuhAgentConnection(
                    queue = queue,
                    host = settings.managerHost,
                    port = settings.agentProtocolPort,
                    identity = identity,
                    keepAliveMessageProvider = { KeepAliveBuilder.build(label) }
                )
                c.onStateChange = { connected, error ->
                    lastConnected = connected
                    lastError = error
                    updateNotification()
                }
                c.start()
                agentConnection = c
            }
        }
    }

    private fun currentSyslogConfig(): SyslogConfig {
        return SyslogConfig(
            host = settings.managerHost,
            port = settings.managerPort,
            useTls = settings.useTls,
            trustAllCerts = settings.trustAllCerts,
            customCaPem = settings.customCaPem,
            hostnameLabel = deviceLabel(),
            facility = settings.syslogFacility
        )
    }

    private fun registerReceivers() {
        // These are all system-only protected broadcasts, so NOT_EXPORTED is correct
        // and required on API 33+ (registerReceiver() throws without an explicit flag).
        networkReceiver = NetworkStateReceiver { if (settings.collectNetworkState) queue.enqueue(it) }
        ContextCompat.registerReceiver(
            this, networkReceiver, NetworkStateReceiver.intentFilter(),
            ContextCompat.RECEIVER_NOT_EXPORTED
        )

        batteryReceiver = BatteryReceiver { if (settings.collectBattery) queue.enqueue(it) }
        ContextCompat.registerReceiver(
            this, batteryReceiver, BatteryReceiver.intentFilter(),
            ContextCompat.RECEIVER_NOT_EXPORTED
        )
    }

    private fun unregisterReceivers() {
        try { networkReceiver?.let { unregisterReceiver(it) } } catch (ignored: Exception) {}
        try { batteryReceiver?.let { unregisterReceiver(it) } } catch (ignored: Exception) {}
    }

    private fun startPeriodicCollectors() {
        val thread = HandlerThread("wazuh-agent-collectors").apply { start() }
        handlerThread = thread
        val h = Handler(thread.looper)
        handler = h

        // Device info: once immediately, then every 30 minutes. Checked on every
        // tick (not just when scheduled) so toggling it off in Settings takes
        // effect immediately without restarting the agent.
        val deviceInfoRunnable = object : Runnable {
            override fun run() {
                if (settings.collectDeviceInfo) queue.enqueue(DeviceInfoCollector.collect(applicationContext))
                h.postDelayed(this, DEVICE_INFO_INTERVAL_MS)
            }
        }
        h.post(deviceInfoRunnable)

        // Installed-app deltas every 15 minutes; a full inventory snapshot once a day.
        val appsRunnable = object : Runnable {
            var lastSnapshot = 0L
            override fun run() {
                val now = System.currentTimeMillis()
                if (settings.collectInstalledApps) {
                    val forceSnapshot = now - lastSnapshot > SNAPSHOT_INTERVAL_MS
                    if (forceSnapshot) lastSnapshot = now
                    val deltaEvents = installedAppsCollector.collectDeltasAndMaybeSnapshot(forceSnapshot)
                    deltaEvents.forEach { queue.enqueue(it) }
                    if (settings.collectYaraScan) scanNewOrUpdatedApks(deltaEvents)
                }
                h.postDelayed(this, APPS_CHECK_INTERVAL_MS)
            }
        }
        h.post(appsRunnable)

        // Full system log (logcat), only if both the in-app toggle is on AND
        // READ_LOGS was separately granted via ADB — SystemLogCollector.collect()
        // simply returns nothing if the permission isn't actually there, so this
        // is safe to poll on a schedule even before that ADB step has been done.
        val systemLogRunnable = object : Runnable {
            override fun run() {
                if (settings.collectFullSystemLogs) {
                    val (event, newestTs) = SystemLogCollector.collect(settings.lastSystemLogTimestamp)
                    if (event != null) queue.enqueue(event)
                    if (newestTs != null) settings.lastSystemLogTimestamp = newestTs
                }
                h.postDelayed(this, SYSTEM_LOG_CHECK_INTERVAL_MS)
            }
        }
        h.post(systemLogRunnable)
    }

    /**
     * Runs the YARA scan only against packages that just showed up as
     * app_installed/app_updated in this cycle's deltas — not a continuous
     * full-device sweep — so cost is bounded to "one APK, once, when it
     * actually changes."
     */
    private fun scanNewOrUpdatedApks(deltaEvents: List<LogEvent>) {
        val customRules = settings.customYaraRules
        deltaEvents
            .filter { it.msgId == "app_installed" || it.msgId == "app_updated" }
            .forEach { evt ->
                val pkg = evt.fields["package_name"] as? String ?: return@forEach
                val apkPath = try {
                    applicationContext.packageManager.getApplicationInfo(pkg, 0).sourceDir
                } catch (e: Exception) {
                    null // app was uninstalled again between the delta check and this scan
                }
                yaraScanner.scanApk(pkg, apkPath, customRules).forEach { queue.enqueue(it) }
            }
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID, "Wazuh Agent Status", NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "Shows that the Wazuh Android Agent is actively monitoring this device."
            }
            val nm = getSystemService(NotificationManager::class.java)
            nm.createNotificationChannel(channel)
        }
    }

    private fun buildNotification(status: String): android.app.Notification {
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Wazuh Agent — monitoring active")
            .setContentText(status)
            .setSmallIcon(android.R.drawable.ic_lock_lock)
            .setOngoing(true)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .build()
    }

    private fun updateNotification() {
        val port = if (settings.transportMode == TransportMode.WAZUH_AGENT) settings.agentProtocolPort else settings.managerPort
        val status = if (lastConnected) "Connected to ${settings.managerHost}:$port"
        else "Reconnecting… (${lastError ?: "waiting"})"
        val nm = getSystemService(NotificationManager::class.java)
        nm.notify(NOTIFICATION_ID, buildNotification(status))
    }

    companion object {
        private const val CHANNEL_ID = "wazuh_agent_status"
        private const val NOTIFICATION_ID = 1
        private const val DEVICE_INFO_INTERVAL_MS = 30 * 60 * 1000L
        private const val APPS_CHECK_INTERVAL_MS = 15 * 60 * 1000L
        private const val SNAPSHOT_INTERVAL_MS = 24 * 60 * 60 * 1000L
        private const val SYSTEM_LOG_CHECK_INTERVAL_MS = 5 * 60 * 1000L
    }
}

/** Small shim so we don't need to wire up Gradle's generated BuildConfig for one field. */
private object BuildConfigShim {
    const val VERSION_NAME = "0.1.0"
}
app/src/main/java/org/wazuh/androidagent/BootReceiver.kt 17 lines
package org.wazuh.androidagent

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat

/** Restarts the agent after device boot, only if the user had it enabled. */
class BootReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
        val settings = SettingsStore(context)
        if (!settings.agentEnabled || !settings.isConfigured()) return
        val serviceIntent = Intent(context, AgentForegroundService::class.java)
        ContextCompat.startForegroundService(context, serviceIntent)
    }
}
app/src/main/java/org/wazuh/androidagent/SettingsStore.kt 249 lines
package org.wazuh.androidagent

import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.wazuh.androidagent.net.Facility
import org.wazuh.androidagent.wazuhagent.AgentIdentity

enum class TransportMode { SYSLOG, WAZUH_AGENT }

/**
 * Persists the manager destination and collection toggles. Backed by
 * EncryptedSharedPreferences when the Android Keystore is available, falling
 * back to plain SharedPreferences (e.g. on emulators without StrongBox/TEE).
 */
class SettingsStore(context: Context) {

    private val prefs: SharedPreferences = try {
        val masterKey = MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()
        EncryptedSharedPreferences.create(
            context,
            "wazuh_agent_settings",
            masterKey,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
        )
    } catch (e: Exception) {
        Log.w("SettingsStore", "Falling back to unencrypted prefs: ${e.message}")
        context.getSharedPreferences("wazuh_agent_settings_plain", Context.MODE_PRIVATE)
    }

    var managerHost: String
        get() = prefs.getString(KEY_HOST, "") ?: ""
        set(value) = prefs.edit().putString(KEY_HOST, value).apply()

    var managerPort: Int
        get() = prefs.getInt(KEY_PORT, 6514)
        set(value) = prefs.edit().putInt(KEY_PORT, value).apply()

    var useTls: Boolean
        get() = prefs.getBoolean(KEY_TLS, true)
        set(value) = prefs.edit().putBoolean(KEY_TLS, value).apply()

    /** INSECURE: accepts any TLS certificate. Off by default; only for lab/testing against self-signed setups. */
    var trustAllCerts: Boolean
        get() = prefs.getBoolean(KEY_TRUST_ALL, false)
        set(value) = prefs.edit().putBoolean(KEY_TRUST_ALL, value).apply()

    /** Optional PEM-encoded custom CA certificate to trust, for internal Wazuh deployments with private CAs. */
    var customCaPem: String
        get() = prefs.getString(KEY_CUSTOM_CA, "") ?: ""
        set(value) = prefs.edit().putString(KEY_CUSTOM_CA, value).apply()

    var deviceLabel: String
        get() = prefs.getString(KEY_DEVICE_LABEL, "") ?: ""
        set(value) = prefs.edit().putString(KEY_DEVICE_LABEL, value).apply()

    var syslogFacility: Int
        get() = prefs.getInt(KEY_FACILITY, Facility.LOCAL0)
        set(value) = prefs.edit().putInt(KEY_FACILITY, value).apply()

    var agentEnabled: Boolean
        get() = prefs.getBoolean(KEY_ENABLED, false)
        set(value) = prefs.edit().putBoolean(KEY_ENABLED, value).apply()

    /** Off by default: keep only notification metadata (package/category/timestamp), not the text content. */
    var includeNotificationText: Boolean
        get() = prefs.getBoolean(KEY_NOTIF_TEXT, false)
        set(value) = prefs.edit().putBoolean(KEY_NOTIF_TEXT, value).apply()

    var collectNotifications: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_NOTIF, true)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_NOTIF, value).apply()

    // --- Per-category log toggles. All default on; flipping one off both stops
    // that category from being queued for the manager AND from appearing in the
    // live log view, since both read from the same enqueue() choke point. ---

    var collectDeviceInfo: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_DEVICE_INFO, true)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_DEVICE_INFO, value).apply()

    var collectInstalledApps: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_APPS, true)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_APPS, value).apply()

    var collectNetworkState: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_NETWORK, true)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_NETWORK, value).apply()

    var collectBattery: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_BATTERY, true)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_BATTERY, value).apply()

    /** Scans newly installed/updated APKs against the bundled + custom YARA rulesets. */
    var collectYaraScan: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_YARA, true)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_YARA, value).apply()

    /** Additional .yar rule text (same supported subset as the bundled ruleset), appended to it. */
    var customYaraRules: String
        get() = prefs.getString(KEY_CUSTOM_YARA, "") ?: ""
        set(value) = prefs.edit().putString(KEY_CUSTOM_YARA, value).apply()

    /**
     * Reads the device's full Android system log (logcat) via SystemLogCollector -
     * every app's output, not just this one. Off by default and deliberately a
     * separate, more consequential opt-in than the other toggles: even with this
     * on, nothing is actually read unless android.permission.READ_LOGS has also
     * been granted out-of-band via ADB, since Android gives third-party apps no
     * in-app way to request or hold that permission. See the app's website for
     * the exact `adb shell pm grant` command.
     */
    var collectFullSystemLogs: Boolean
        get() = prefs.getBoolean(KEY_COLLECT_SYSLOG, false)
        set(value) = prefs.edit().putBoolean(KEY_COLLECT_SYSLOG, value).apply()

    /** Logcat's own "MM-DD HH:MM:SS.mmm" timestamp of the newest line already forwarded,
     *  so each collection cycle only reads what's new. Empty means "never collected yet". */
    var lastSystemLogTimestamp: String
        get() = prefs.getString(KEY_SYSLOG_LAST_TS, "") ?: ""
        set(value) = prefs.edit().putString(KEY_SYSLOG_LAST_TS, value).apply()

    fun isConfigured(): Boolean = managerHost.isNotBlank() && managerPort in 1..65535

    // --- Wazuh agent protocol (real enrollment) ---

    var transportMode: TransportMode
        get() = try {
            TransportMode.valueOf(prefs.getString(KEY_TRANSPORT_MODE, TransportMode.SYSLOG.name)!!)
        } catch (e: Exception) {
            TransportMode.SYSLOG
        }
        set(value) = prefs.edit().putString(KEY_TRANSPORT_MODE, value.name).apply()

    var authdPort: Int
        get() = prefs.getInt(KEY_AUTHD_PORT, 1515)
        set(value) = prefs.edit().putInt(KEY_AUTHD_PORT, value).apply()

    var agentProtocolPort: Int
        get() = prefs.getInt(KEY_AGENT_PORT, 1514)
        set(value) = prefs.edit().putInt(KEY_AGENT_PORT, value).apply()

    /** Optional authd enrollment password (only needed if the manager requires one). */
    var enrollmentPassword: String
        get() = prefs.getString(KEY_ENROLL_PASSWORD, "") ?: ""
        set(value) = prefs.edit().putString(KEY_ENROLL_PASSWORD, value).apply()

    var agentId: String
        get() = prefs.getString(KEY_AGENT_ID, "") ?: ""
        set(value) = prefs.edit().putString(KEY_AGENT_ID, value).apply()

    var agentName: String
        get() = prefs.getString(KEY_AGENT_NAME, "") ?: ""
        set(value) = prefs.edit().putString(KEY_AGENT_NAME, value).apply()

    var agentKey: String
        get() = prefs.getString(KEY_AGENT_KEY, "") ?: ""
        set(value) = prefs.edit().putString(KEY_AGENT_KEY, value).apply()

    fun isEnrolled(): Boolean = agentId.isNotBlank() && agentName.isNotBlank() && agentKey.isNotBlank()

    fun agentIdentity(): AgentIdentity? =
        if (isEnrolled()) AgentIdentity(agentId, agentName, agentKey) else null

    fun clearEnrollment() {
        prefs.edit().remove(KEY_AGENT_ID).remove(KEY_AGENT_NAME).remove(KEY_AGENT_KEY).apply()
    }

    // --- Wazuh REST API + Claude AI log analysis (separate from the always-on
    // agent above: an on-demand screen that authenticates to the Wazuh
    // manager's API, pulls recent manager logs, and asks Claude to analyze
    // them). Shares the manager's TLS trust settings (trustAllCerts/customCaPem)
    // above rather than duplicating them, since it's normally the same host. ---

    var wazuhApiHost: String
        get() = prefs.getString(KEY_API_HOST, "") ?: ""
        set(value) = prefs.edit().putString(KEY_API_HOST, value).apply()

    var wazuhApiPort: Int
        get() = prefs.getInt(KEY_API_PORT, 55000)
        set(value) = prefs.edit().putInt(KEY_API_PORT, value).apply()

    var wazuhApiUsername: String
        get() = prefs.getString(KEY_API_USER, "") ?: ""
        set(value) = prefs.edit().putString(KEY_API_USER, value).apply()

    var wazuhApiPassword: String
        get() = prefs.getString(KEY_API_PASSWORD, "") ?: ""
        set(value) = prefs.edit().putString(KEY_API_PASSWORD, value).apply()

    /** How many recent manager log entries to fetch per analysis run. */
    var logFetchLimit: Int
        get() = prefs.getInt(KEY_LOG_FETCH_LIMIT, 200)
        set(value) = prefs.edit().putInt(KEY_LOG_FETCH_LIMIT, value).apply()

    /** Stored locally only (EncryptedSharedPreferences); never baked into the APK. */
    var claudeApiKey: String
        get() = prefs.getString(KEY_CLAUDE_API_KEY, "") ?: ""
        set(value) = prefs.edit().putString(KEY_CLAUDE_API_KEY, value).apply()

    var claudeModel: String
        get() = prefs.getString(KEY_CLAUDE_MODEL, "claude-opus-5") ?: "claude-opus-5"
        set(value) = prefs.edit().putString(KEY_CLAUDE_MODEL, value).apply()

    fun isAiAnalysisConfigured(): Boolean =
        wazuhApiHost.isNotBlank() && wazuhApiPort in 1..65535 &&
            wazuhApiUsername.isNotBlank() && wazuhApiPassword.isNotBlank() &&
            claudeApiKey.isNotBlank() && claudeModel.isNotBlank()

    companion object {
        private const val KEY_HOST = "manager_host"
        private const val KEY_PORT = "manager_port"
        private const val KEY_TLS = "use_tls"
        private const val KEY_TRUST_ALL = "trust_all_certs"
        private const val KEY_CUSTOM_CA = "custom_ca_pem"
        private const val KEY_DEVICE_LABEL = "device_label"
        private const val KEY_FACILITY = "syslog_facility"
        private const val KEY_ENABLED = "agent_enabled"
        private const val KEY_NOTIF_TEXT = "include_notification_text"
        private const val KEY_COLLECT_NOTIF = "collect_notifications"
        private const val KEY_COLLECT_DEVICE_INFO = "collect_device_info"
        private const val KEY_COLLECT_APPS = "collect_installed_apps"
        private const val KEY_COLLECT_NETWORK = "collect_network_state"
        private const val KEY_COLLECT_BATTERY = "collect_battery"
        private const val KEY_COLLECT_YARA = "collect_yara_scan"
        private const val KEY_CUSTOM_YARA = "custom_yara_rules"
        private const val KEY_COLLECT_SYSLOG = "collect_full_system_logs"
        private const val KEY_SYSLOG_LAST_TS = "system_log_last_timestamp"
        private const val KEY_TRANSPORT_MODE = "transport_mode"
        private const val KEY_AUTHD_PORT = "authd_port"
        private const val KEY_AGENT_PORT = "agent_protocol_port"
        private const val KEY_ENROLL_PASSWORD = "enrollment_password"
        private const val KEY_AGENT_ID = "agent_id"
        private const val KEY_AGENT_NAME = "agent_name"
        private const val KEY_AGENT_KEY = "agent_key"
        private const val KEY_API_HOST = "wazuh_api_host"
        private const val KEY_API_PORT = "wazuh_api_port"
        private const val KEY_API_USER = "wazuh_api_username"
        private const val KEY_API_PASSWORD = "wazuh_api_password"
        private const val KEY_LOG_FETCH_LIMIT = "log_fetch_limit"
        private const val KEY_CLAUDE_API_KEY = "claude_api_key"
        private const val KEY_CLAUDE_MODEL = "claude_model"
    }
}

Wazuh agent protocol

The real enrollment + encrypted transport handshake — authd (1515) and remoted (1514).

app/src/main/java/org/wazuh/androidagent/wazuhagent/AgentCrypto.kt 149 lines
package org.wazuh.androidagent.wazuhagent

import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.zip.Deflater
import java.util.zip.Inflater
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec

/**
 * Implements Wazuh's agent<->manager "secure message" wire format (AES variant):
 * https://documentation.wazuh.com/current/development/message-format.html
 *
 * Verified byte-for-byte against Wazuh's own QA agent simulator
 * (wazuh/wazuh-qa: deps/wazuh_testing/wazuh_testing/tools/agent_simulator.py and
 * remoted_sim.py) and validated live against a real Wazuh 4.14 manager.
 *
 * Message layout: MD5(random+global:local:msg) + random+global:local:msg
 *   -> zlib compress -> pad with '!' to a multiple of 8 bytes (prepended)
 *   -> PKCS7-pad to a multiple of 16 -> AES-256-CBC with a fixed IV
 *   -> prefixed with "!<agent_id>!#AES:"
 */
object AgentCrypto {

    // Fixed IV used by the real protocol for both directions (not a secret; the
    // per-agent key is what actually protects the traffic).
    private val AES_IV = "FEDCBA0987654321".toByteArray(Charsets.US_ASCII)

    /**
     * Derives the 47-byte encryption key from the agent's enrollment identity.
     * Only the first 32 bytes (an MD5 hex digest, so already valid AES-256 key
     * material) are used for AES; the full 47 bytes exist for legacy Blowfish
     * compatibility, which this implementation does not use.
     */
    fun deriveKey(agentId: String, agentName: String, rawKey: String): ByteArray {
        val md5NameHex = md5Hex(agentName.toByteArray(Charsets.UTF_8))
        val md5IdHex = md5Hex(agentId.toByteArray(Charsets.UTF_8))
        val sum1 = md5Hex((md5NameHex + md5IdHex).toByteArray(Charsets.US_ASCII)).substring(0, 15)
        val sum2 = md5Hex(rawKey.toByteArray(Charsets.UTF_8))
        return (sum2 + sum1).toByteArray(Charsets.US_ASCII)
    }

    /** Builds one ready-to-send, framed-less event. Caller adds the 4-byte TCP length prefix. */
    fun createEvent(message: String, agentId: String, encryptionKey: ByteArray): ByteArray {
        val msgBytes = message.toByteArray(Charsets.UTF_8)
        val random = "55555".toByteArray(Charsets.US_ASCII)
        val globalCounter = "1234567891".toByteArray(Charsets.US_ASCII)
        val localCounter = "5555".toByteArray(Charsets.US_ASCII)
        val colon = ":".toByteArray(Charsets.US_ASCII)

        val body = random + globalCounter + colon + localCounter + colon + msgBytes
        val checksum = md5Hex(body).toByteArray(Charsets.US_ASCII)
        val composed = checksum + body

        val compressed = zlibCompress(composed)
        val padded = wazuhPad(compressed)

        val aesKey = encryptionKey.copyOfRange(0, 32)
        val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
        cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(AES_IV))
        val encrypted = cipher.doFinal(padded)

        val header = "!$agentId!#AES:".toByteArray(Charsets.US_ASCII)
        return header + encrypted
    }

    /**
     * Best-effort decrypt of a manager->agent message. Returns null on any
     * failure (unsupported cipher, corrupt frame, partial read) rather than
     * throwing - this path is only used to detect commands worth ACKing, and
     * must never be allowed to take down the connection.
     */
    fun decryptEvent(data: ByteArray, encryptionKey: ByteArray): String? {
        return try {
            var offset = 0
            if (data.isNotEmpty() && data[0] == '!'.code.toByte()) {
                val secondBang = data.indexOfFrom(1, '!'.code.toByte())
                if (secondBang > 0) offset = secondBang + 1
            }
            if (data.size < offset + 5 || String(data, offset, 5, Charsets.US_ASCII) != "#AES:") {
                return null // unsupported (e.g. legacy blowfish ":") or malformed
            }
            val cipherText = data.copyOfRange(offset + 5, data.size)
            val aesKey = encryptionKey.copyOfRange(0, 32)
            val cipher = Cipher.getInstance("AES/CBC/NoPadding")
            cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(AES_IV))
            val decrypted = cipher.doFinal(cipherText)

            var padStart = 0
            while (padStart < decrypted.size && decrypted[padStart] == '!'.code.toByte()) padStart++
            val decompressed = zlibDecompressTolerant(decrypted, padStart) ?: return null
            String(decompressed, Charsets.ISO_8859_1)
        } catch (e: Exception) {
            null
        }
    }

    private fun md5Hex(data: ByteArray): String {
        val digest = MessageDigest.getInstance("MD5").digest(data)
        return digest.joinToString("") { "%02x".format(it) }
    }

    private fun wazuhPad(data: ByteArray): ByteArray {
        val extra = data.size % 8
        val padLen = if (extra > 0) 8 - extra else 8
        val pad = ByteArray(padLen) { '!'.code.toByte() }
        return pad + data
    }

    private fun zlibCompress(data: ByteArray): ByteArray {
        val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, false) // nowrap=false == zlib framing (matches Python's zlib.compress)
        deflater.setInput(data)
        deflater.finish()
        val out = ByteArrayOutputStream()
        val buf = ByteArray(4096)
        while (!deflater.finished()) {
            val n = deflater.deflate(buf)
            out.write(buf, 0, n)
        }
        deflater.end()
        return out.toByteArray()
    }

    /** Decompresses, tolerating trailing PKCS7 padding bytes after the deflate stream ends. */
    private fun zlibDecompressTolerant(data: ByteArray, offset: Int): ByteArray? {
        val inflater = Inflater(false)
        inflater.setInput(data, offset, data.size - offset)
        val out = ByteArrayOutputStream()
        val buf = ByteArray(4096)
        return try {
            while (!inflater.finished()) {
                val n = inflater.inflate(buf)
                if (n == 0 && inflater.needsInput()) break
                out.write(buf, 0, n)
            }
            inflater.end()
            out.toByteArray()
        } catch (e: Exception) {
            inflater.end()
            null
        }
    }

    private fun ByteArray.indexOfFrom(start: Int, value: Byte): Int {
        for (i in start until size) if (this[i] == value) return i
        return -1
    }
}
app/src/main/java/org/wazuh/androidagent/wazuhagent/AuthdClient.kt 60 lines
package org.wazuh.androidagent.wazuhagent

import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.Socket
import javax.net.ssl.SSLSocket

data class EnrollmentResult(val agentId: String, val agentName: String, val agentKey: String)

class EnrollmentException(message: String) : Exception(message)

/**
 * Speaks Wazuh's agent enrollment protocol to authd (TLS, port 1515 by default):
 * connect, send "OSSEC A:'<name>'" (optionally with a password), and parse the
 * "OSSEC K:'<id> <name> <ip> <key>'" response.
 */
object AuthdClient {

    fun enroll(
        host: String,
        port: Int,
        agentName: String,
        password: String?,
        sslSocketFactory: javax.net.ssl.SSLSocketFactory,
        timeoutMs: Int = 15_000
    ): EnrollmentResult {
        val plain = Socket()
        plain.connect(java.net.InetSocketAddress(host, port), timeoutMs)
        val socket = sslSocketFactory.createSocket(plain, host, port, true) as SSLSocket
        socket.soTimeout = timeoutMs
        try {
            socket.startHandshake()

            val request = if (password.isNullOrBlank()) {
                "OSSEC A:'$agentName'\n"
            } else {
                "OSSEC PASS: $password OSSEC A:'$agentName'\n"
            }
            socket.outputStream.write(request.toByteArray(Charsets.UTF_8))
            socket.outputStream.flush()

            val reader = BufferedReader(InputStreamReader(socket.inputStream, Charsets.UTF_8))
            val response = reader.readLine() ?: throw EnrollmentException("No response from authd")

            if (response.startsWith("ERROR")) {
                throw EnrollmentException(response)
            }
            // Expected: OSSEC K:'<id> <name> <ip> <key>'
            val parts = response.split("'")
            if (parts.size < 2) throw EnrollmentException("Unrecognized authd response: $response")
            val fields = parts[1].trim().split(" ")
            if (fields.size < 4) throw EnrollmentException("Unrecognized authd key fields: ${parts[1]}")

            return EnrollmentResult(agentId = fields[0], agentName = fields[1], agentKey = fields[3])
        } finally {
            try { socket.close() } catch (ignored: Exception) {}
            try { plain.close() } catch (ignored: Exception) {}
        }
    }
}
app/src/main/java/org/wazuh/androidagent/wazuhagent/KeepAliveBuilder.kt 23 lines
package org.wazuh.androidagent.wazuhagent

import android.os.Build

/**
 * Builds the "hostinfo" style keepalive line real Wazuh agents send, which
 * the manager parses to populate an agent's OS/version fields in the
 * dashboard. Format confirmed against Wazuh's QA data
 * (deps/wazuh_testing/wazuh_testing/data/keepalives.txt) and a live manager.
 */
object KeepAliveBuilder {

    fun build(deviceLabel: String, agentVersion: String = "v4.7.0", mergedChecksum: String = STATIC_CHECKSUM): String {
        val osLine = "#!-Linux |$deviceLabel |${Build.VERSION.RELEASE}-android-${Build.VERSION.SDK_INT} " +
            "|${Build.FINGERPRINT} |${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"} " +
            "[Android|android: ${Build.VERSION.RELEASE}] - Wazuh $agentVersion / $mergedChecksum"
        return "$osLine\n$mergedChecksum merged.mg"
    }

    // We don't participate in Wazuh's shared-config group sync, so this is a
    // static placeholder rather than a real merge checksum.
    private const val STATIC_CHECKSUM = "abcdef0123456789abcdef0123456789"
}
app/src/main/java/org/wazuh/androidagent/wazuhagent/WazuhAgentConnection.kt 190 lines
package org.wazuh.androidagent.wazuhagent

import android.util.Log
import org.wazuh.androidagent.net.LogQueue
import java.io.DataInputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket
import kotlin.math.min

data class AgentIdentity(val agentId: String, val agentName: String, val agentKey: String) {
    val encryptionKey: ByteArray by lazy { AgentCrypto.deriveKey(agentId, agentName, agentKey) }
}

/**
 * Maintains the persistent, encrypted TCP connection to the Wazuh manager's
 * remoted (port 1514 by default): sends the startup event once per connection,
 * a periodic keepalive (so the agent shows "active" with correct OS/version
 * info in the dashboard), and drains [LogQueue] as regular log events tagged
 * with [locationTag].
 *
 * Also runs a reader loop that ACKs manager control messages ("#!-..." frames,
 * e.g. the shared-config/group-file push). Without this, the manager retries
 * those pushes in a tight loop indefinitely - confirmed against a live 4.14
 * manager during development.
 */
class WazuhAgentConnection(
    private val queue: LogQueue,
    private val host: String,
    private val port: Int,
    private val identity: AgentIdentity,
    private val keepAliveMessageProvider: () -> String,
    private val keepAliveIntervalMs: Long = 10_000,
    private val locationTag: String = "wazuh-android-agent"
) {
    @Volatile private var running = false
    @Volatile private var connected = false
    private var thread: Thread? = null
    private var readerThread: Thread? = null

    private var socket: Socket? = null
    private var out: OutputStream? = null
    private val writeLock = Any()

    var onStateChange: ((connected: Boolean, lastError: String?) -> Unit)? = null
    var sentCount: Long = 0
        private set

    fun isConnected(): Boolean = connected

    fun start() {
        if (running) return
        running = true
        thread = Thread(::loop, "wazuh-agent-connection").apply {
            isDaemon = true
            start()
        }
    }

    fun stop() {
        running = false
        closeQuietly()
        thread?.interrupt()
        readerThread?.interrupt()
        thread = null
        readerThread = null
    }

    private fun loop() {
        var backoffMs = 1000L
        while (running) {
            try {
                connect()
                sendFrame(AgentCrypto.createEvent("#!-agent startup ", identity.agentId, identity.encryptionKey))

                var lastKeepAlive = 0L
                while (running && connected) {
                    val now = System.currentTimeMillis()
                    if (now - lastKeepAlive >= keepAliveIntervalMs) {
                        sendFrame(AgentCrypto.createEvent(keepAliveMessageProvider(), identity.agentId, identity.encryptionKey))
                        lastKeepAlive = now
                    }

                    val batch = queue.peekBatch(20)
                    if (batch.isNotEmpty()) {
                        var sent = 0
                        for (event in batch) {
                            val msg = "x:$locationTag:${event.toJson()}"
                            sendFrame(AgentCrypto.createEvent(msg, identity.agentId, identity.encryptionKey))
                            sent++
                        }
                        queue.removeFront(sent)
                        sentCount += sent
                    } else {
                        Thread.sleep(500)
                    }
                }
                backoffMs = 1000L
            } catch (e: InterruptedException) {
                break
            } catch (e: Exception) {
                Log.w(TAG, "Connection loop error: ${e.message}")
                closeQuietly()
                onStateChange?.invoke(false, e.message)
                try {
                    Thread.sleep(backoffMs)
                } catch (ignored: InterruptedException) {
                    break
                }
                backoffMs = min(backoffMs * 2, 60_000L)
            }
        }
        closeQuietly()
    }

    private fun connect() {
        closeQuietly()
        val s = Socket()
        s.connect(InetSocketAddress(host, port), 10_000)
        s.soTimeout = 0 // reader thread blocks indefinitely; writes use a separate lock
        socket = s
        out = s.getOutputStream()
        connected = true
        onStateChange?.invoke(true, null)
        Log.i(TAG, "Connected to $host:$port as agent ${identity.agentId}")

        readerThread = Thread({ readLoop(s) }, "wazuh-agent-reader").apply {
            isDaemon = true
            start()
        }
    }

    private fun readLoop(s: Socket) {
        try {
            val input = DataInputStream(s.getInputStream())
            val lenBuf = ByteArray(4)
            while (running && !s.isClosed) {
                input.readFully(lenBuf)
                val len = (lenBuf[0].toInt() and 0xFF) or
                    ((lenBuf[1].toInt() and 0xFF) shl 8) or
                    ((lenBuf[2].toInt() and 0xFF) shl 16) or
                    ((lenBuf[3].toInt() and 0xFF) shl 24)
                if (len <= 0 || len > 10_000_000) break // sanity bound
                val payload = ByteArray(len)
                input.readFully(payload)

                val decrypted = AgentCrypto.decryptEvent(payload, identity.encryptionKey)
                if (decrypted != null && decrypted.startsWith("#!-")) {
                    // Acknowledge control messages (e.g. shared-config push) so the
                    // manager doesn't spin retrying them.
                    try {
                        sendFrame(AgentCrypto.createEvent("#!-agent ack ", identity.agentId, identity.encryptionKey))
                    } catch (ignored: Exception) {
                    }
                }
            }
        } catch (e: Exception) {
            // Connection dropped or closed - the main loop's reconnect logic handles this.
            Log.d(TAG, "Reader loop ended: ${e.message}")
        }
    }

    private fun sendFrame(event: ByteArray) {
        synchronized(writeLock) {
            val o = out ?: throw IllegalStateException("Not connected")
            val length = byteArrayOf(
                (event.size and 0xFF).toByte(),
                ((event.size shr 8) and 0xFF).toByte(),
                ((event.size shr 16) and 0xFF).toByte(),
                ((event.size shr 24) and 0xFF).toByte()
            )
            o.write(length)
            o.write(event)
            o.flush()
        }
    }

    private fun closeQuietly() {
        connected = false
        try { out?.flush() } catch (ignored: Exception) {}
        try { out?.close() } catch (ignored: Exception) {}
        try { socket?.close() } catch (ignored: Exception) {}
        out = null
        socket = null
    }

    companion object {
        private const val TAG = "WazuhAgentConnection"
    }
}

Wazuh REST API + Claude

The on-demand AI Analysis screen's two clients — Wazuh's manager API and Claude's Messages API.

app/src/main/java/org/wazuh/androidagent/wazuhapi/WazuhApiClient.kt 121 lines
package org.wazuh.androidagent.wazuhapi

import org.json.JSONObject
import org.wazuh.androidagent.net.TlsUtil
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection

/**
 * Minimal client for the Wazuh manager's REST API (default port 55000) —
 * distinct from `authd`/the agent wire protocol in [org.wazuh.androidagent.wazuhagent],
 * which this app also speaks. Used only by the on-demand "API & AI Analysis"
 * screen, not the always-on foreground agent.
 *
 * Deliberately raw [HttpsURLConnection], matching this project's existing
 * choice (see AuthdClient/WazuhAgentConnection) to avoid a new networking
 * dependency (OkHttp/Retrofit) — Wazuh's API here is two simple JSON calls,
 * not worth a library, and this keeps the build free of extra transitive deps.
 */
object WazuhApiClient {

    data class LogEntry(val timestamp: String, val level: String, val tag: String, val description: String) {
        override fun toString(): String = "[$timestamp] [$level] $tag: $description"
    }

    class WazuhApiException(message: String) : IOException(message)

    /**
     * POST /security/user/authenticate with HTTP Basic auth -> short-lived JWT.
     * The Wazuh API user needs at least the `manager:read` (or equivalent
     * `GET /manager/logs`) RBAC permission — see the app README.
     */
    fun authenticate(
        host: String,
        port: Int,
        username: String,
        password: String,
        trustAllCerts: Boolean,
        customCaPem: String?
    ): String {
        val url = URL("https://$host:$port/security/user/authenticate")
        val conn = openConnection(url, trustAllCerts, customCaPem)
        try {
            conn.requestMethod = "POST"
            val basic = android.util.Base64.encodeToString(
                "$username:$password".toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP
            )
            conn.setRequestProperty("Authorization", "Basic $basic")
            conn.doOutput = false
            val body = readResponse(conn, "authenticate")
            return JSONObject(body).getJSONObject("data").getString("token")
        } finally {
            conn.disconnect()
        }
    }

    /**
     * GET /manager/logs — the manager's own ossec.log entries, newest first.
     * This is literally "the Wazuh log file" surfaced over the API, as
     * distinct from indexed alerts (which live in the separate Wazuh Indexer/
     * OpenSearch component, not the manager API — not implemented here).
     */
    fun fetchManagerLogs(
        host: String,
        port: Int,
        token: String,
        limit: Int,
        trustAllCerts: Boolean,
        customCaPem: String?
    ): List<LogEntry> {
        val cappedLimit = limit.coerceIn(1, 5000)
        val url = URL("https://$host:$port/manager/logs?limit=$cappedLimit&sort=-timestamp")
        val conn = openConnection(url, trustAllCerts, customCaPem)
        try {
            conn.requestMethod = "GET"
            conn.setRequestProperty("Authorization", "Bearer $token")
            val body = readResponse(conn, "fetch manager logs")
            val items = JSONObject(body).getJSONObject("data").getJSONArray("affected_items")
            return (0 until items.length()).map { i ->
                val o = items.getJSONObject(i)
                LogEntry(
                    timestamp = o.optString("timestamp", ""),
                    level = o.optString("level", ""),
                    tag = o.optString("tag", ""),
                    description = o.optString("description", "")
                )
            }
        } finally {
            conn.disconnect()
        }
    }

    private fun openConnection(url: URL, trustAllCerts: Boolean, customCaPem: String?): HttpURLConnection {
        val conn = url.openConnection() as HttpURLConnection
        if (conn is HttpsURLConnection) {
            conn.sslSocketFactory = TlsUtil.buildSocketFactory(customCaPem, trustAllCerts)
            TlsUtil.buildHostnameVerifier(trustAllCerts)?.let { conn.hostnameVerifier = it }
        }
        conn.connectTimeout = 15_000
        conn.readTimeout = 30_000
        conn.setRequestProperty("Content-Type", "application/json")
        return conn
    }

    /** Reads the response body regardless of status, then raises with the Wazuh API's own error message on failure. */
    private fun readResponse(conn: HttpURLConnection, action: String): String {
        val code = conn.responseCode
        val stream = if (code in 200..299) conn.inputStream else conn.errorStream
        val body = stream?.bufferedReader()?.use { it.readText() } ?: ""
        if (code !in 200..299) {
            val detail = try {
                JSONObject(body).let { it.optJSONArray("detail")?.toString() ?: it.optString("detail", body) }
            } catch (e: Exception) {
                body
            }
            throw WazuhApiException("Wazuh API: failed to $action (HTTP $code): ${detail.ifBlank { "no details" }}")
        }
        return body
    }
}
app/src/main/java/org/wazuh/androidagent/claude/ClaudeClient.kt 110 lines
package org.wazuh.androidagent.claude

import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL

/**
 * Minimal client for the Claude Messages API (`POST /v1/messages`), used to
 * analyze Wazuh manager logs fetched by [org.wazuh.androidagent.wazuhapi.WazuhApiClient].
 *
 * There is no official Anthropic SDK targeting Android specifically, and the
 * general Java SDK pulls in a substantial dependency tree (Jackson, OkHttp,
 * ...) this project has deliberately avoided everywhere else (see
 * AuthdClient/WazuhAgentConnection/WazuhApiClient — all raw sockets/
 * HttpsURLConnection). This is a single non-streaming JSON call, so raw HTTP
 * keeps that same footprint rather than adding a new dependency + risking
 * Android/AndroidX version conflicts in an environment with no device to
 * test the resulting build against.
 */
object ClaudeClient {

    private const val API_URL = "https://api.anthropic.com/v1/messages"
    private const val ANTHROPIC_VERSION = "2023-06-01"

    class ClaudeApiException(message: String) : IOException(message)

    /**
     * Sends [userPrompt] as a single user turn and returns Claude's text reply.
     * Non-streaming: max_tokens is kept modest (see [maxTokens] default) so a
     * mobile foreground request completes well within [HttpURLConnection]'s
     * read timeout without needing to parse a server-sent-events stream by hand.
     */
    fun analyze(
        apiKey: String,
        model: String,
        systemPrompt: String,
        userPrompt: String,
        maxTokens: Int = 4096
    ): String {
        val conn = URL(API_URL).openConnection() as HttpURLConnection
        try {
            conn.requestMethod = "POST"
            conn.doOutput = true
            conn.connectTimeout = 20_000
            conn.readTimeout = 180_000 // analysis can take a while; this is an interactive on-demand call, not the background agent
            conn.setRequestProperty("x-api-key", apiKey)
            conn.setRequestProperty("anthropic-version", ANTHROPIC_VERSION)
            conn.setRequestProperty("content-type", "application/json")

            val requestBody = JSONObject().apply {
                put("model", model)
                put("max_tokens", maxTokens)
                put("system", systemPrompt)
                put("messages", JSONArray().put(
                    JSONObject().put("role", "user").put("content", userPrompt)
                ))
            }
            conn.outputStream.use { it.write(requestBody.toString().toByteArray(Charsets.UTF_8)) }

            val code = conn.responseCode
            val stream = if (code in 200..299) conn.inputStream else conn.errorStream
            val body = stream?.bufferedReader()?.use { it.readText() } ?: ""

            if (code !in 200..299) {
                throw ClaudeApiException(describeError(code, body))
            }

            val response = JSONObject(body)
            val stopReason = response.optString("stop_reason", "")
            if (stopReason == "refusal") {
                val category = response.optJSONObject("stop_details")?.optString("category", "unspecified") ?: "unspecified"
                throw ClaudeApiException("Claude declined to analyze this content (category: $category).")
            }

            val content = response.optJSONArray("content") ?: JSONArray()
            val text = StringBuilder()
            for (i in 0 until content.length()) {
                val block = content.getJSONObject(i)
                if (block.optString("type") == "text") {
                    text.append(block.optString("text", ""))
                }
            }
            if (text.isEmpty()) {
                throw ClaudeApiException("Claude returned no text content (stop_reason=$stopReason)")
            }
            return text.toString()
        } finally {
            conn.disconnect()
        }
    }

    private fun describeError(code: Int, body: String): String {
        val apiMessage = try {
            JSONObject(body).optJSONObject("error")?.optString("message")
        } catch (e: Exception) {
            null
        } ?: body.ifBlank { "no details" }

        val prefix = when (code) {
            401 -> "Invalid Claude API key"
            403 -> "Claude API key lacks permission for this request"
            429 -> "Rate limited by the Claude API — try again shortly"
            in 500..599 -> "Claude API server error"
            else -> "Claude API request failed (HTTP $code)"
        }
        return "$prefix: $apiMessage"
    }
}

Collectors

What actually generates telemetry — each independently toggleable, off by default.

app/src/main/java/org/wazuh/androidagent/collectors/DeviceInfoCollector.kt 70 lines
package org.wazuh.androidagent.collectors

import android.app.KeyguardManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.provider.Settings
import java.io.File
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity

/**
 * Periodic snapshot of device identity and security posture: OS version/patch
 * level, screen-lock state, storage encryption, VPN presence, and a best-effort
 * root heuristic. Emitted at service start and on a slow interval thereafter.
 */
object DeviceInfoCollector {

    fun collect(context: Context): LogEvent {
        val androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
        val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
        val battery = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager

        val fields = mapOf(
            "android_id" to androidId,
            "manufacturer" to Build.MANUFACTURER,
            "model" to Build.MODEL,
            "brand" to Build.BRAND,
            "device" to Build.DEVICE,
            "android_release" to Build.VERSION.RELEASE,
            "sdk_int" to Build.VERSION.SDK_INT,
            "security_patch" to Build.VERSION.SECURITY_PATCH,
            "build_fingerprint" to Build.FINGERPRINT,
            "build_tags" to Build.TAGS,
            "device_secure" to (keyguard?.isDeviceSecure ?: false),
            "keyguard_locked" to (keyguard?.isKeyguardLocked ?: false),
            "vpn_active" to isVpnActive(context),
            "battery_pct" to (battery?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1),
            "root_indicators" to rootIndicators()
        )
        return LogEvent(
            timestamp = java.time.Instant.now(),
            msgId = "device_info",
            severity = Severity.INFO,
            fields = fields
        )
    }

    private fun isVpnActive(context: Context): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
        val network = cm.activeNetwork ?: return false
        val caps = cm.getNetworkCapabilities(network) ?: return false
        return caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
    }

    /** Heuristic only (no root access is required or used) — common indicators of a rooted device. */
    private fun rootIndicators(): List<String> {
        val hits = mutableListOf<String>()
        val suPaths = listOf(
            "/system/bin/su", "/system/xbin/su", "/sbin/su",
            "/system/app/Superuser.apk", "/data/local/xbin/su", "/data/local/bin/su"
        )
        for (p in suPaths) if (File(p).exists()) hits.add("su_binary:$p")
        if (Build.TAGS != null && Build.TAGS.contains("test-keys")) hits.add("test_keys_build")
        if (File("/system/app/Magisk").exists() || File("/sbin/.magisk").exists()) hits.add("magisk")
        return hits
    }
}
app/src/main/java/org/wazuh/androidagent/collectors/InstalledAppsCollector.kt 164 lines
package org.wazuh.androidagent.collectors

import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import org.json.JSONObject
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.io.File
import java.time.Instant

/**
 * Enumerates installed packages and reports:
 *  - a full inventory snapshot (low cadence: on first run / once per day), and
 *  - install/update/removal deltas (checked on every collection cycle) so a
 *    newly sideloaded app shows up as its own security-relevant event.
 *
 * State (which packages were last seen, at which versionCode) is persisted to
 * a small JSON file so deltas survive process restarts.
 */
class InstalledAppsCollector(private val context: Context) {

    private val stateFile = File(context.filesDir, "known_apps.json")

    fun collectDeltasAndMaybeSnapshot(forceSnapshot: Boolean = false): List<LogEvent> {
        val pm = context.packageManager
        val packages: List<PackageInfo> = try {
            pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
        } catch (e: Exception) {
            Log.e(TAG, "getInstalledPackages failed", e)
            return emptyList()
        }

        val current = mutableMapOf<String, Long>() // package -> versionCode
        for (p in packages) current[p.packageName] = longVersionCode(p)

        val previous = loadState()
        val events = mutableListOf<LogEvent>()

        for (p in packages) {
            val prevVersion = previous[p.packageName]
            if (prevVersion == null) {
                events.add(appEvent("app_installed", p, pm))
            } else if (prevVersion != longVersionCode(p)) {
                events.add(appEvent("app_updated", p, pm))
            }
        }
        for (pkg in previous.keys) {
            if (!current.containsKey(pkg)) {
                events.add(
                    LogEvent(
                        timestamp = Instant.now(),
                        msgId = "app_removed",
                        severity = Severity.NOTICE,
                        fields = mapOf("package_name" to pkg)
                    )
                )
            }
        }

        if (forceSnapshot) {
            events.add(snapshotEvent(packages))
        }

        saveState(current)
        return events
    }

    private fun appEvent(msgId: String, p: PackageInfo, pm: PackageManager): LogEvent {
        val appInfo: ApplicationInfo? = p.applicationInfo
        val isSystem = appInfo != null && (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
        val installer = try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                pm.getInstallSourceInfo(p.packageName).installingPackageName
            } else {
                @Suppress("DEPRECATION")
                pm.getInstallerPackageName(p.packageName)
            }
        } catch (e: Exception) {
            null
        }
        val dangerousPerms = (p.requestedPermissions ?: arrayOf())
            .filter { it.contains("DANGEROUS", ignoreCase = false) || isLikelyDangerous(it) }

        return LogEvent(
            timestamp = Instant.now(),
            msgId = msgId,
            severity = if (msgId == "app_installed") Severity.NOTICE else Severity.INFO,
            fields = mapOf(
                "package_name" to p.packageName,
                "version_name" to p.versionName,
                "version_code" to longVersionCode(p),
                "is_system_app" to isSystem,
                "installer_package" to installer,
                "first_install_time" to Instant.ofEpochMilli(p.firstInstallTime).toString(),
                "last_update_time" to Instant.ofEpochMilli(p.lastUpdateTime).toString(),
                "requested_permissions" to (p.requestedPermissions?.toList() ?: emptyList<String>()),
                "sensitive_permissions_requested" to dangerousPerms
            )
        )
    }

    private fun snapshotEvent(packages: List<PackageInfo>): LogEvent {
        val summaries = packages.map { p ->
            mapOf(
                "package_name" to p.packageName,
                "version_code" to longVersionCode(p),
                "is_system_app" to ((p.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0)
            )
        }
        return LogEvent(
            timestamp = Instant.now(),
            msgId = "installed_apps_snapshot",
            severity = Severity.INFO,
            fields = mapOf("count" to packages.size, "apps" to summaries)
        )
    }

    private fun isLikelyDangerous(perm: String): Boolean {
        val sensitiveKeywords = listOf(
            "SMS", "CALL_LOG", "CONTACTS", "LOCATION", "CAMERA", "RECORD_AUDIO",
            "READ_PHONE_STATE", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE",
            "ACCESSIBILITY", "BIND_DEVICE_ADMIN", "SYSTEM_ALERT_WINDOW"
        )
        return sensitiveKeywords.any { perm.contains(it) }
    }

    private fun longVersionCode(p: PackageInfo): Long =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) p.longVersionCode
        else @Suppress("DEPRECATION") p.versionCode.toLong()

    private fun loadState(): Map<String, Long> {
        if (!stateFile.exists()) return emptyMap()
        return try {
            val obj = JSONObject(stateFile.readText())
            val map = mutableMapOf<String, Long>()
            val keys = obj.keys()
            while (keys.hasNext()) {
                val k = keys.next()
                map[k] = obj.getLong(k)
            }
            map
        } catch (e: Exception) {
            emptyMap()
        }
    }

    private fun saveState(state: Map<String, Long>) {
        try {
            val obj = JSONObject()
            for ((k, v) in state) obj.put(k, v)
            stateFile.writeText(obj.toString())
        } catch (e: Exception) {
            Log.e(TAG, "Failed to persist known_apps state", e)
        }
    }

    companion object {
        private const val TAG = "InstalledAppsCollector"
    }
}
app/src/main/java/org/wazuh/androidagent/collectors/SystemLogCollector.kt 116 lines
package org.wazuh.androidagent.collectors

import android.util.Log
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.io.BufferedReader
import java.io.InputStreamReader
import java.time.Instant

/**
 * Reads the Android system log (logcat) — every app's output on the device,
 * not just this one — via `Runtime.exec("logcat")`. This only produces
 * anything once android.permission.READ_LOGS has been granted out-of-band
 * via ADB (`adb shell pm grant org.wazuh.androidagent android.permission.READ_LOGS`);
 * there is no runtime prompt or in-app Settings toggle that can grant it, by
 * design on modern Android, since a third-party app reading every other
 * app's log output is a meaningfully bigger privacy surface than anything
 * else this project collects. See the app's website for the exact command.
 * Gated behind SettingsStore.collectFullSystemLogs, off by default, on top
 * of the ADB grant — both have to be true.
 *
 * NOTE: this collector could not be exercised against a real device in this
 * build environment (no adb/emulator available here — see the project
 * README's "Build env quirks" section). The logcat invocation and the `-T`
 * time-filter format below match long-stable, documented AOSP logcat
 * behavior, but treat this specific path as unverified until it's actually
 * run against real hardware with the permission granted.
 */
object SystemLogCollector {

    private const val TAG = "SystemLogCollector"

    /** Hard cap per collection cycle so a noisy device can't flood the local queue/manager. */
    private const val MAX_LINES_PER_CYCLE = 300

    /**
     * Dumps logcat since [sinceTimestamp] (logcat's own "MM-DD HH:MM:SS.mmm" format,
     * exactly as printed by `-v threadtime` — blank means "the current buffer, once").
     * Returns a single batched event (or null if nothing new, or logcat isn't
     * readable — most commonly because READ_LOGS isn't actually granted) plus the
     * timestamp of the newest line seen, to persist for the next cycle.
     */
    fun collect(sinceTimestamp: String): Pair<LogEvent?, String?> {
        val cmd = if (sinceTimestamp.isBlank()) {
            arrayOf("logcat", "-d", "-v", "threadtime")
        } else {
            arrayOf("logcat", "-d", "-v", "threadtime", "-T", sinceTimestamp)
        }

        val lines = try {
            val process = Runtime.getRuntime().exec(cmd)
            val out = BufferedReader(InputStreamReader(process.inputStream)).readLines()
            process.waitFor()
            out
        } catch (e: Exception) {
            // Almost always means READ_LOGS isn't actually granted (or this ROM's
            // logcat isn't reachable via exec()). Fail silently and let the next
            // cycle try again rather than spamming a warning every tick for what's
            // usually just "the ADB step hasn't been done yet."
            Log.d(TAG, "logcat unavailable: ${e.message}")
            return null to null
        }

        if (lines.isEmpty()) return null to null

        var newestTimestamp: String? = null
        val kept = ArrayList<String>(minOf(lines.size, MAX_LINES_PER_CYCLE))
        for (line in lines) {
            extractTimestamp(line)?.let { newestTimestamp = it }
            if (kept.size < MAX_LINES_PER_CYCLE) kept.add(line)
        }
        if (kept.isEmpty()) return null to newestTimestamp

        val event = LogEvent(
            timestamp = Instant.now(),
            msgId = "system_log_batch",
            severity = highestSeverity(kept),
            fields = mapOf(
                "line_count" to kept.size,
                "truncated" to (lines.size > kept.size),
                "lines" to kept
            )
        )
        return event to (newestTimestamp ?: sinceTimestamp.ifBlank { null })
    }

    /** threadtime format starts every line "MM-DD HH:MM:SS.mmm ..."; logcat's own -T
     *  filter accepts exactly that substring back, so no reformatting is needed. */
    private fun extractTimestamp(line: String): String? =
        TIMESTAMP_REGEX.find(line)?.value

    private fun highestSeverity(lines: List<String>): Int {
        var worst = Severity.DEBUG // numerically highest = least severe; we want the lowest number seen
        for (line in lines) {
            val level = levelOf(line) ?: continue
            if (level < worst) worst = level
        }
        return worst
    }

    /** The single level letter logcat prints right after the PID/TID columns in threadtime format. */
    private fun levelOf(line: String): Int? {
        val m = LEVEL_REGEX.find(line) ?: return null
        return when (m.groupValues[1]) {
            "F" -> Severity.CRITICAL
            "E" -> Severity.ERROR
            "W" -> Severity.WARNING
            "I" -> Severity.INFO
            "D", "V" -> Severity.DEBUG
            else -> null
        }
    }

    private val TIMESTAMP_REGEX = Regex("""^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}""")
    private val LEVEL_REGEX = Regex("""\d+\s+\d+\s+([VDIWEF])\s""")
}
app/src/main/java/org/wazuh/androidagent/collectors/NetworkStateReceiver.kt 52 lines
package org.wazuh.androidagent.collectors

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.time.Instant

/**
 * Reports connectivity transitions: type (WiFi/cellular/none), VPN presence,
 * and metered state. SSID/IP are intentionally omitted by default to avoid
 * collecting fine-grained location-adjacent data without an explicit ask.
 */
class NetworkStateReceiver(private val onEvent: (LogEvent) -> Unit) : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
        val network = cm.activeNetwork
        val caps = network?.let { cm.getNetworkCapabilities(it) }

        val transport = when {
            caps == null -> "none"
            caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
            caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
            caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
            caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
            else -> "other"
        }

        onEvent(
            LogEvent(
                timestamp = Instant.now(),
                msgId = "network_state",
                severity = Severity.INFO,
                fields = mapOf(
                    "transport" to transport,
                    "connected" to (caps != null),
                    "vpn_active" to (caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ?: false),
                    "metered" to (caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false)
                )
            )
        )
    }

    companion object {
        fun intentFilter(): android.content.IntentFilter =
            android.content.IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
    }
}
app/src/main/java/org/wazuh/androidagent/collectors/BatteryReceiver.kt 44 lines
package org.wazuh.androidagent.collectors

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.time.Instant

/** Reports charging-state transitions and periodic battery level snapshots. */
class BatteryReceiver(private val onEvent: (LogEvent) -> Unit) : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        val msgId = when (intent.action) {
            Intent.ACTION_POWER_CONNECTED -> "power_connected"
            Intent.ACTION_POWER_DISCONNECTED -> "power_disconnected"
            else -> "battery_status"
        }
        val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
        val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
        val pct = if (level >= 0 && scale > 0) (level * 100 / scale) else -1
        val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
        val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL

        onEvent(
            LogEvent(
                timestamp = Instant.now(),
                msgId = msgId,
                severity = Severity.INFO,
                fields = mapOf("battery_pct" to pct, "charging" to charging)
            )
        )
    }

    companion object {
        fun intentFilter(): IntentFilter = IntentFilter().apply {
            addAction(Intent.ACTION_BATTERY_CHANGED)
            addAction(Intent.ACTION_POWER_CONNECTED)
            addAction(Intent.ACTION_POWER_DISCONNECTED)
        }
    }
}
app/src/main/java/org/wazuh/androidagent/collectors/NotificationAgentListenerService.kt 68 lines
package org.wazuh.androidagent.collectors

import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import org.wazuh.androidagent.SettingsStore
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.net.Severity
import java.time.Instant

/**
 * Requires the user to explicitly grant Notification Access in system Settings
 * (this cannot be requested via a runtime permission dialog). By default only
 * metadata is logged (package, category, posted time); full title/text is
 * opt-in via Settings > "Include notification text", off by default.
 */
class NotificationAgentListenerService : NotificationListenerService() {

    private lateinit var queue: LogQueue
    private lateinit var settings: SettingsStore

    override fun onCreate() {
        super.onCreate()
        queue = LogQueue(applicationContext)
        settings = SettingsStore(applicationContext)
    }

    override fun onNotificationPosted(sbn: StatusBarNotification) {
        if (!settings.agentEnabled || !settings.collectNotifications) return
        // Don't log our own foreground-service notification.
        if (sbn.packageName == applicationContext.packageName) return

        val extras = sbn.notification.extras
        val fields = mutableMapOf<String, Any?>(
            "package_name" to sbn.packageName,
            "posted_at" to Instant.ofEpochMilli(sbn.postTime).toString(),
            "category" to sbn.notification.category,
            "is_ongoing" to sbn.isOngoing
        )
        if (settings.includeNotificationText) {
            fields["title"] = extras?.getCharSequence("android.title")?.toString()
            fields["text"] = extras?.getCharSequence("android.text")?.toString()
        }

        queue.enqueue(
            LogEvent(
                timestamp = Instant.now(),
                msgId = "notification_posted",
                severity = Severity.INFO,
                fields = fields
            )
        )
    }

    override fun onNotificationRemoved(sbn: StatusBarNotification) {
        if (!settings.agentEnabled || !settings.collectNotifications) return
        if (sbn.packageName == applicationContext.packageName) return

        queue.enqueue(
            LogEvent(
                timestamp = Instant.now(),
                msgId = "notification_removed",
                severity = Severity.DEBUG,
                fields = mapOf("package_name" to sbn.packageName)
            )
        )
    }
}

Transport & queue

Durable local queuing, the syslog fallback transport, and shared TLS handling.

app/src/main/java/org/wazuh/androidagent/net/LogEvent.kt 77 lines
package org.wazuh.androidagent.net

import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant

/**
 * Syslog severities (RFC 5424 section 6.2.1).
 */
object Severity {
    const val EMERGENCY = 0
    const val ALERT = 1
    const val CRITICAL = 2
    const val ERROR = 3
    const val WARNING = 4
    const val NOTICE = 5
    const val INFO = 6
    const val DEBUG = 7
}

/** Syslog facility. We use local0 (16) by default; security/authorization (10) is offered as an alternative. */
object Facility {
    const val SECURITY_AUTH = 10
    const val LOCAL0 = 16
}

/**
 * One unit of telemetry produced by a collector, queued locally, and eventually
 * shipped to the Wazuh manager as a syslog message.
 */
data class LogEvent(
    val timestamp: Instant,
    val msgId: String,
    val severity: Int,
    val fields: Map<String, Any?>
) {
    fun toJson(): String {
        val obj = JSONObject()
        obj.put("event_type", msgId)
        obj.put("timestamp", timestamp.toString())
        obj.put("severity", severity)
        val f = JSONObject()
        for ((k, v) in fields) f.put(k, toJsonValue(v))
        obj.put("data", f)
        return obj.toString()
    }

    private fun toJsonValue(v: Any?): Any? = when (v) {
        null -> JSONObject.NULL
        is List<*> -> JSONArray().apply { v.forEach { put(toJsonValue(it)) } }
        is Map<*, *> -> JSONObject().apply { v.forEach { (k, mv) -> put(k.toString(), toJsonValue(mv)) } }
        else -> v
    }

    companion object {
        fun fromJsonLine(line: String): LogEvent? {
            return try {
                val obj = JSONObject(line)
                val data = obj.getJSONObject("data")
                val map = mutableMapOf<String, Any?>()
                val keys = data.keys()
                while (keys.hasNext()) {
                    val k = keys.next()
                    map[k] = data.get(k)
                }
                LogEvent(
                    timestamp = Instant.parse(obj.getString("timestamp")),
                    msgId = obj.getString("event_type"),
                    severity = obj.optInt("severity", Severity.INFO),
                    fields = map
                )
            } catch (e: Exception) {
                null
            }
        }
    }
}
app/src/main/java/org/wazuh/androidagent/net/LogQueue.kt 95 lines
package org.wazuh.androidagent.net

import android.content.Context
import android.util.Log
import java.io.File
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

/**
 * A simple, durable, file-backed FIFO queue for [LogEvent]s. Events are appended
 * as one JSON object per line to a file under the app's private storage, so
 * telemetry survives process death and network outages. Bounded so a device that
 * stays offline for a long time doesn't fill the disk (oldest events are dropped).
 */
class LogQueue(context: Context) {

    private val file = File(context.filesDir, "pending_events.jsonl")
    private val lock = ReentrantLock()

    var droppedCount: Long = 0
        private set

    fun enqueue(event: LogEvent) {
        lock.withLock {
            try {
                file.appendText(event.toJson() + "\n")
                trimIfNeeded()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to enqueue event", e)
            }
        }
        // Every event that's queued for delivery is also fanned out to the live
        // log view. A category toggled off never reaches enqueue() in the first
        // place (collectors check settings before calling this), so the live
        // view and the manager see exactly the same filtered stream.
        LiveLogBus.publish(event)
    }

    /** Returns up to [max] oldest events without removing them. Call [removeFront] after a successful send. */
    fun peekBatch(max: Int): List<LogEvent> {
        lock.withLock {
            if (!file.exists()) return emptyList()
            return file.bufferedReader().useLines { lines ->
                lines.take(max).mapNotNull { LogEvent.fromJsonLine(it) }.toList()
            }
        }
    }

    fun size(): Int {
        lock.withLock {
            if (!file.exists()) return 0
            return file.bufferedReader().useLines { it.count() }
        }
    }

    /** Removes the oldest [count] entries after they were successfully delivered. */
    fun removeFront(count: Int) {
        lock.withLock {
            if (!file.exists() || count <= 0) return
            val remaining = file.bufferedReader().useLines { it.drop(count).toList() }
            val tmp = File(file.parentFile, file.name + ".tmp")
            tmp.bufferedWriter().use { w ->
                for (line in remaining) {
                    w.write(line)
                    w.newLine()
                }
            }
            tmp.renameTo(file)
        }
    }

    private fun trimIfNeeded() {
        if (file.length() <= MAX_FILE_BYTES) return
        val allLines = file.bufferedReader().useLines { it.toList() }
        if (allLines.size <= MAX_LINES) return
        val overflow = allLines.size - MAX_LINES
        droppedCount += overflow
        val keep = allLines.drop(overflow)
        val tmp = File(file.parentFile, file.name + ".tmp")
        tmp.bufferedWriter().use { w ->
            for (line in keep) {
                w.write(line)
                w.newLine()
            }
        }
        tmp.renameTo(file)
        Log.w(TAG, "Queue exceeded bounds, dropped $overflow oldest events")
    }

    companion object {
        private const val TAG = "LogQueue"
        private const val MAX_LINES = 20_000
        private const val MAX_FILE_BYTES = 20L * 1024 * 1024 // 20MB
    }
}
app/src/main/java/org/wazuh/androidagent/net/LiveLogBus.kt 48 lines
package org.wazuh.androidagent.net

import android.os.Handler
import android.os.Looper
import java.util.Collections

/**
 * In-process fan-out of every [LogEvent] the agent produces, for the "Live Log"
 * screen. Deliberately not persisted and not the same thing as [LogQueue]:
 * this is a bounded, memory-only ring buffer purely for on-device visibility
 * while the app is open, so it doesn't inherit LogQueue's durability/retry
 * concerns and can't affect delivery to the manager.
 */
object LiveLogBus {

    private const val MAX_BUFFERED = 500

    private val mainHandler = Handler(Looper.getMainLooper())
    private val buffer = ArrayDeque<LogEvent>(MAX_BUFFERED)
    private val listeners = Collections.synchronizedSet(mutableSetOf<(LogEvent) -> Unit>())

    @Synchronized
    fun publish(event: LogEvent) {
        buffer.addLast(event)
        while (buffer.size > MAX_BUFFERED) buffer.removeFirst()
        val snapshot = listeners.toList()
        if (snapshot.isEmpty()) return
        mainHandler.post {
            for (l in snapshot) l(event)
        }
    }

    @Synchronized
    fun recent(): List<LogEvent> = buffer.toList()

    fun addListener(listener: (LogEvent) -> Unit) {
        listeners.add(listener)
    }

    fun removeListener(listener: (LogEvent) -> Unit) {
        listeners.remove(listener)
    }

    @Synchronized
    fun clear() {
        buffer.clear()
    }
}
app/src/main/java/org/wazuh/androidagent/net/SyslogFormatter.kt 33 lines
package org.wazuh.androidagent.net

import android.os.Process
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

/**
 * Formats [LogEvent]s as RFC 5424 syslog messages, newline-delimited for
 * transport over TCP (the convention understood by rsyslog's imtcp and by
 * Wazuh's remote syslog listener).
 */
class SyslogFormatter(
    private val hostname: String,
    private val facility: Int,
    private val appName: String = "WazuhAndroidAgent"
) {
    private val procId = Process.myPid().toString()
    private val timestampFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME

    fun format(event: LogEvent): String {
        val pri = facility * 8 + event.severity
        val ts = event.timestamp.atOffset(ZoneOffset.UTC).format(timestampFormatter)
        val host = sanitize(hostname.ifBlank { "android-device" })
        val msgId = sanitize(event.msgId).ifBlank { "event" }
        val msg = event.toJson()
        // <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
        return "<$pri>1 $ts $host $appName $procId $msgId - $msg"
    }

    /** RFC5424 header fields must be printable US-ASCII with no spaces. */
    private fun sanitize(s: String): String =
        s.filter { it.code in 33..126 }.take(48).ifBlank { "-" }
}
app/src/main/java/org/wazuh/androidagent/net/SyslogSender.kt 147 lines
package org.wazuh.androidagent.net

import android.util.Log
import java.io.BufferedOutputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket
import javax.net.ssl.SSLSocket
import kotlin.math.min

data class SyslogConfig(
    val host: String,
    val port: Int,
    val useTls: Boolean,
    val trustAllCerts: Boolean,
    val customCaPem: String,
    val hostnameLabel: String,
    val facility: Int
)

/**
 * Owns a long-lived TCP (optionally TLS) connection to the Wazuh manager's
 * remote-syslog listener and continuously drains [LogQueue], one line-delimited
 * RFC5424 message per event. Reconnects with exponential backoff on failure so
 * a temporarily unreachable manager doesn't lose telemetry.
 */
class SyslogSender(
    private val queue: LogQueue,
    private var config: SyslogConfig
) {
    @Volatile private var running = false
    @Volatile private var connected = false
    private var thread: Thread? = null

    var onStateChange: ((connected: Boolean, lastError: String?) -> Unit)? = null
    var sentCount: Long = 0
        private set

    fun updateConfig(newConfig: SyslogConfig) {
        config = newConfig
        // Force a reconnect on next loop iteration with the new settings.
        closeQuietly()
    }

    fun isConnected(): Boolean = connected

    private var socket: Socket? = null
    private var out: OutputStream? = null

    fun start() {
        if (running) return
        running = true
        thread = Thread(::loop, "wazuh-syslog-sender").apply {
            isDaemon = true
            start()
        }
    }

    fun stop() {
        running = false
        closeQuietly()
        thread?.interrupt()
        thread = null
    }

    private fun loop() {
        var backoffMs = 1000L
        while (running) {
            try {
                if (config.host.isBlank()) {
                    Thread.sleep(2000)
                    continue
                }
                ensureConnected()
                val batch = queue.peekBatch(50)
                if (batch.isEmpty()) {
                    Thread.sleep(1000)
                    continue
                }
                val formatter = SyslogFormatter(config.hostnameLabel, config.facility)
                var sentInBatch = 0
                for (event in batch) {
                    val line = formatter.format(event) + "\n"
                    out?.write(line.toByteArray(Charsets.UTF_8))
                    sentInBatch++
                }
                out?.flush()
                queue.removeFront(sentInBatch)
                sentCount += sentInBatch
                backoffMs = 1000L
            } catch (e: InterruptedException) {
                break
            } catch (e: Exception) {
                Log.w(TAG, "Send loop error: ${e.message}")
                closeQuietly()
                onStateChange?.invoke(false, e.message)
                try {
                    Thread.sleep(backoffMs)
                } catch (ignored: InterruptedException) {
                    break
                }
                backoffMs = min(backoffMs * 2, 60_000L)
            }
        }
        closeQuietly()
    }

    private fun ensureConnected() {
        if (connected && socket?.isConnected == true && socket?.isClosed == false) return
        closeQuietly()

        val plainSocket = Socket()
        plainSocket.connect(InetSocketAddress(config.host, config.port), 10_000)
        plainSocket.soTimeout = 15_000

        val finalSocket: Socket = if (config.useTls) {
            val factory = TlsUtil.buildSocketFactory(
                config.customCaPem.ifBlank { null },
                config.trustAllCerts
            )
            val tlsSocket = factory.createSocket(plainSocket, config.host, config.port, true) as SSLSocket
            tlsSocket.startHandshake()
            tlsSocket
        } else {
            plainSocket
        }

        socket = finalSocket
        out = BufferedOutputStream(finalSocket.getOutputStream())
        connected = true
        onStateChange?.invoke(true, null)
        Log.i(TAG, "Connected to ${config.host}:${config.port} (tls=${config.useTls})")
    }

    private fun closeQuietly() {
        connected = false
        try { out?.flush() } catch (ignored: Exception) {}
        try { out?.close() } catch (ignored: Exception) {}
        try { socket?.close() } catch (ignored: Exception) {}
        out = null
        socket = null
    }

    companion object {
        private const val TAG = "SyslogSender"
    }
}
app/src/main/java/org/wazuh/androidagent/net/TlsUtil.kt 102 lines
package org.wazuh.androidagent.net

import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager

object TlsUtil {

    /**
     * Hostname verifier for [javax.net.ssl.HttpsURLConnection]-based clients
     * (the Wazuh REST API and Claude API clients). [SSLSocketFactory] alone
     * only covers certificate trust; HttpsURLConnection separately checks the
     * hostname against the cert unless this is overridden too. Only relevant
     * when [trustAll] is set (same INSECURE lab/testing opt-in as above) —
     * with a custom/default CA, the platform's normal hostname check applies.
     */
    fun buildHostnameVerifier(trustAll: Boolean): HostnameVerifier? =
        if (trustAll) HostnameVerifier { _, _ -> true } else null

    /**
     * Builds an [SSLSocketFactory] for talking to the Wazuh manager.
     *
     * - By default, trusts the system CA store plus, if provided, one extra
     *   PEM-encoded CA certificate (typical for an internal Wazuh deployment
     *   with a private CA).
     * - If [trustAll] is true, certificate/hostname validation is disabled
     *   entirely. This is INSECURE and intended only for lab/testing against
     *   a self-signed manager; it must be explicitly opted into in Settings.
     */
    fun buildSocketFactory(customCaPem: String?, trustAll: Boolean): SSLSocketFactory {
        val context = SSLContext.getInstance("TLS")
        if (trustAll) {
            context.init(null, arrayOf<TrustManager>(InsecureTrustManager()), SecureRandom())
        } else {
            val trustManagers = buildTrustManagers(customCaPem)
            context.init(null, trustManagers, SecureRandom())
        }
        return context.socketFactory
    }

    private fun buildTrustManagers(customCaPem: String?): Array<TrustManager> {
        val defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
        defaultTmf.init(null as KeyStore?) // system default trust store

        if (customCaPem.isNullOrBlank()) {
            return defaultTmf.trustManagers
        }

        // Build a KeyStore containing the system CAs + the custom CA, so both are trusted.
        val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
        keyStore.load(null, null)

        val cf = CertificateFactory.getInstance("X.509")
        val cert = cf.generateCertificate(ByteArrayInputStream(customCaPem.toByteArray())) as X509Certificate
        keyStore.setCertificateEntry("wazuh_custom_ca", cert)

        val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
        tmf.init(keyStore)

        // Combine custom-CA trust manager with system defaults so both public and private CAs work.
        return arrayOf(CombinedTrustManager(tmf.trustManagers.filterIsInstance<X509TrustManager>(),
            defaultTmf.trustManagers.filterIsInstance<X509TrustManager>()))
    }

    private class CombinedTrustManager(
        private val a: List<X509TrustManager>,
        private val b: List<X509TrustManager>
    ) : X509TrustManager {
        override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
            throw java.security.cert.CertificateException("Client cert auth not supported")
        }

        override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
            var lastError: java.security.cert.CertificateException? = null
            for (tm in a + b) {
                try {
                    tm.checkServerTrusted(chain, authType)
                    return
                } catch (e: java.security.cert.CertificateException) {
                    lastError = e
                }
            }
            throw lastError ?: java.security.cert.CertificateException("No trust manager could verify the chain")
        }

        override fun getAcceptedIssuers(): Array<X509Certificate> = (a + b).flatMap { it.acceptedIssuers.toList() }.toTypedArray()
    }

    private class InsecureTrustManager : X509TrustManager {
        override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
        override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
        override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
    }
}

YARA engine

A hand-written parser and matcher for a deliberately scoped subset of YARA syntax — see YaraParser.kt's header for exactly what's supported.

app/src/main/java/org/wazuh/androidagent/yara/YaraModel.kt 55 lines
package org.wazuh.androidagent.yara

/**
 * Data model for the YARA rule subset this engine understands.
 *
 * This is NOT a full YARA implementation (see YaraParser.kt header for exactly
 * what's supported and what isn't). It exists so this app can scan newly
 * installed APKs against .yar-format rule text without depending on a native
 * libyara build, which isn't buildable/testable in this environment (no NDK
 * toolchain set up, no device to validate a compiled .so against).
 */

sealed class YaraPattern {
    /** One or more byte-encodings to search for (e.g. ascii + wide variants of the same text). */
    data class Text(val variants: List<ByteArray>, val nocase: Boolean, val fullword: Boolean) : YaraPattern()

    /** Raw byte sequence with a per-position wildcard mask (true = '??', matches any byte). */
    data class Hex(val bytes: ByteArray, val mask: BooleanArray) : YaraPattern()

    /** Regex matched against the content decoded as ISO-8859-1 (1 char == 1 byte, no charset surprises). */
    data class Regex(val pattern: kotlin.text.Regex) : YaraPattern()
}

data class YaraString(val id: String, val pattern: YaraPattern)

sealed class OfCount {
    object All : OfCount()
    object Any : OfCount()
    data class N(val n: Int) : OfCount()
}

sealed class CondNode {
    data class StrRef(val id: String) : CondNode()
    data class BoolLit(val v: Boolean) : CondNode()
    data class Not(val n: CondNode) : CondNode()
    data class And(val a: CondNode, val b: CondNode) : CondNode()
    data class Or(val a: CondNode, val b: CondNode) : CondNode()
    /** ids == null means "them" (every string defined in the rule). */
    data class OfThem(val count: OfCount, val ids: List<String>?) : CondNode()
    /** filesize <op> N(KB|MB) */
    data class SizeCmp(val op: String, val bytes: Long) : CondNode()
}

data class YaraRule(
    val name: String,
    val tags: List<String>,
    val meta: Map<String, String>,
    val strings: List<YaraString>,
    val condition: CondNode
)

class YaraParseException(message: String) : Exception(message)

/** Result of matching one rule against one blob of content. */
data class YaraMatch(val rule: YaraRule, val matchedStringIds: List<String>)
app/src/main/java/org/wazuh/androidagent/yara/YaraParser.kt 479 lines
package org.wazuh.androidagent.yara

/**
 * Hand-written recursive-descent parser for a deliberately scoped SUBSET of
 * YARA's rule syntax. Written from scratch against the public YARA language
 * documentation (no native libyara available to link against in this build
 * environment — see the project README's "YARA scanning" section for why).
 *
 * Supported:
 *  - `rule NAME [: tag1 tag2] { meta: ... strings: ... condition: ... }`
 *  - meta values: quoted strings, numbers, true/false (parsed, carried through
 *    for reporting; not used in matching)
 *  - string patterns: `"text"` with `ascii` `wide` `nocase` `fullword`
 *    modifiers (any combination); `{ AA BB ?? CC }` hex byte patterns with
 *    full-byte `??` wildcards; `/regex/` with an optional `nocase` modifier
 *  - conditions: `and` `or` `not`, parentheses, bare `$id` (truthy if that
 *    string matched), `any of them` / `all of them` / `N of them` /
 *    `N of ($a,$b,...)`, and `filesize` comparisons (`filesize < 5MB`)
 *
 * Explicitly NOT supported (will throw YaraParseException, rule is skipped
 * rather than silently mis-evaluated): YARA modules (pe/elf/cuckoo/...),
 * external variables, `include`, hex jump ranges (`[0-4]`) or nibble-level
 * wildcards (`4?`), string-count (`#a`) or offset (`@a`) operators, rule
 * references (`OtherRule and ...`), private/global rule modifiers.
 */
object YaraParser {

    fun parseRules(source: String): List<YaraRule> {
        val p = Cursor(source)
        val rules = mutableListOf<YaraRule>()
        p.skipWsAndComments()
        while (!p.eof()) {
            rules.add(parseRule(p))
            p.skipWsAndComments()
        }
        return rules
    }

    /** Parses rules one at a time; a rule that fails to parse is skipped (reported via onError) rather than aborting the whole file. */
    fun parseRulesLenient(source: String, onError: (ruleNameOrIndex: String, error: Exception) -> Unit = { _, _ -> }): List<YaraRule> {
        val p = Cursor(source)
        val rules = mutableListOf<YaraRule>()
        p.skipWsAndComments()
        var index = 0
        while (!p.eof()) {
            val startPos = p.pos
            try {
                rules.add(parseRule(p))
            } catch (e: Exception) {
                onError("rule#$index near offset $startPos", e)
                // Recovery: skip forward to the next "rule " keyword so one bad rule
                // doesn't take the rest of the file down with it.
                if (!p.skipToNextRuleKeyword()) break
            }
            index++
            p.skipWsAndComments()
        }
        return rules
    }

    private fun parseRule(p: Cursor): YaraRule {
        p.expectIdent("rule")
        val name = p.parseIdentRaw()
        val tags = mutableListOf<String>()
        p.skipWsAndComments()
        if (p.peek() == ':') {
            p.advance()
            p.skipWsAndComments()
            while (p.peek() != '{') {
                tags.add(p.parseIdentRaw())
                p.skipWsAndComments()
            }
        }
        p.expectChar('{')

        var meta: Map<String, String> = emptyMap()
        var strings: List<YaraString> = emptyList()
        var condition: CondNode? = null

        p.skipWsAndComments()
        while (p.peek() != '}') {
            val section = p.parseIdentRaw()
            p.skipWsAndComments()
            p.expectChar(':')
            when (section) {
                "meta" -> meta = parseMetaSection(p)
                "strings" -> strings = parseStringsSection(p)
                "condition" -> condition = parseCondition(p, strings.map { it.id })
                else -> throw YaraParseException("Unknown section '$section' (expected meta/strings/condition)")
            }
            p.skipWsAndComments()
        }
        p.expectChar('}')

        val cond = condition ?: throw YaraParseException("Rule '$name' has no condition: section")
        return YaraRule(name, tags, meta, strings, cond)
    }

    private fun parseMetaSection(p: Cursor): Map<String, String> {
        val map = LinkedHashMap<String, String>()
        p.skipWsAndComments()
        while (p.peek() != '}' && !p.atSectionKeyword()) {
            val key = p.parseIdentRaw()
            p.skipWsAndComments()
            p.expectChar('=')
            p.skipWsAndComments()
            val value = when {
                p.peek() == '"' -> p.parseQuotedString()
                p.matchIdentAhead("true") -> { p.parseIdentRaw(); "true" }
                p.matchIdentAhead("false") -> { p.parseIdentRaw(); "false" }
                else -> p.parseNumberRaw()
            }
            map[key] = value
            p.skipWsAndComments()
        }
        return map
    }

    private fun parseStringsSection(p: Cursor): List<YaraString> {
        val list = mutableListOf<YaraString>()
        p.skipWsAndComments()
        while (p.peek() != '}' && !p.atSectionKeyword()) {
            p.expectChar('$')
            val id = "$" + p.parseIdentRaw()
            p.skipWsAndComments()
            p.expectChar('=')
            p.skipWsAndComments()
            val pattern: YaraPattern = when {
                p.peek() == '"' -> parseTextPattern(p)
                p.peek() == '{' -> parseHexPattern(p)
                p.peek() == '/' -> parseRegexPattern(p)
                else -> throw YaraParseException("Expected string, hex block, or regex after $id =")
            }
            list.add(YaraString(id, pattern))
            p.skipWsAndComments()
        }
        return list
    }

    private fun parseTextPattern(p: Cursor): YaraPattern.Text {
        val text = p.parseQuotedString()
        var ascii = false
        var wide = false
        var nocase = false
        var fullword = false
        p.skipWsAndComments()
        while (p.matchIdentAhead("ascii") || p.matchIdentAhead("wide") || p.matchIdentAhead("nocase") ||
            p.matchIdentAhead("fullword") || p.matchIdentAhead("xor") || p.matchIdentAhead("base64") ||
            p.matchIdentAhead("base64wide") || p.matchIdentAhead("private")
        ) {
            when (val mod = p.parseIdentRaw()) {
                "ascii" -> ascii = true
                "wide" -> wide = true
                "nocase" -> nocase = true
                "fullword" -> fullword = true
                else -> throw YaraParseException("Unsupported string modifier '$mod'")
            }
            p.skipWsAndComments()
        }
        if (!ascii && !wide) ascii = true // default per YARA spec
        val variants = mutableListOf<ByteArray>()
        if (ascii) variants.add(text.toByteArray(Charsets.ISO_8859_1))
        if (wide) variants.add(textToUtf16LeBytes(text))
        return YaraPattern.Text(variants, nocase, fullword)
    }

    private fun textToUtf16LeBytes(text: String): ByteArray {
        val out = ByteArray(text.length * 2)
        for (i in text.indices) {
            val c = text[i].code
            out[i * 2] = (c and 0xFF).toByte()
            out[i * 2 + 1] = ((c shr 8) and 0xFF).toByte()
        }
        return out
    }

    private fun parseHexPattern(p: Cursor): YaraPattern.Hex {
        p.expectChar('{')
        val bytes = mutableListOf<Byte>()
        val mask = mutableListOf<Boolean>()
        p.skipWsAndComments()
        while (p.peek() != '}') {
            when {
                p.peek() == '?' -> {
                    p.advance()
                    if (p.peek() == '?') p.advance()
                    else throw YaraParseException("Nibble-level hex wildcards ('4?') aren't supported, only full-byte '??'")
                    bytes.add(0)
                    mask.add(true)
                }
                p.peek()?.isHexDigit() == true -> {
                    val hi = p.advance() ?: throw YaraParseException("Truncated hex byte in hex string")
                    val lo = p.advance() ?: throw YaraParseException("Truncated hex byte in hex string")
                    if (!lo.isHexDigit()) throw YaraParseException("Truncated hex byte in hex string")
                    bytes.add(((hexVal(hi) shl 4) or hexVal(lo)).toByte())
                    mask.add(false)
                }
                p.peek() == '[' || p.peek() == '(' -> throw YaraParseException("Hex jump ranges / alternations aren't supported in this subset")
                else -> throw YaraParseException("Unexpected character '${p.peek()}' in hex string")
            }
            p.skipWsAndComments()
        }
        p.expectChar('}')
        if (bytes.isEmpty()) throw YaraParseException("Empty hex string")
        return YaraPattern.Hex(bytes.toByteArray(), mask.toBooleanArray())
    }

    private fun parseRegexPattern(p: Cursor): YaraPattern.Regex {
        p.expectChar('/')
        val sb = StringBuilder()
        while (true) {
            val c = p.advance() ?: throw YaraParseException("Unterminated regex literal")
            if (c == '\\') {
                sb.append(c)
                sb.append(p.advance() ?: throw YaraParseException("Unterminated regex escape"))
                continue
            }
            if (c == '/') break
            sb.append(c)
        }
        var nocase = false
        p.skipWsAndComments()
        while (p.matchIdentAhead("nocase") || p.matchIdentAhead("fullword")) {
            when (val mod = p.parseIdentRaw()) {
                "nocase" -> nocase = true
                "fullword" -> throw YaraParseException("'fullword' on regex patterns isn't supported")
                else -> throw YaraParseException("Unknown regex modifier '$mod'")
            }
            p.skipWsAndComments()
        }
        val opts = if (nocase) setOf(RegexOption.IGNORE_CASE) else emptySet()
        val regex = try {
            kotlin.text.Regex(sb.toString(), opts)
        } catch (e: Exception) {
            throw YaraParseException("Invalid regex /${sb}/: ${e.message}")
        }
        return YaraPattern.Regex(regex)
    }

    // --- condition expression parser (recursive descent, standard precedence: or < and < not) ---

    private fun parseCondition(p: Cursor, knownIds: List<String>): CondNode {
        val node = parseOr(p, knownIds)
        return node
    }

    private fun parseOr(p: Cursor, ids: List<String>): CondNode {
        var left = parseAnd(p, ids)
        p.skipWsAndComments()
        while (p.matchIdentAhead("or")) {
            p.parseIdentRaw()
            p.skipWsAndComments()
            val right = parseAnd(p, ids)
            left = CondNode.Or(left, right)
            p.skipWsAndComments()
        }
        return left
    }

    private fun parseAnd(p: Cursor, ids: List<String>): CondNode {
        var left = parseNot(p, ids)
        p.skipWsAndComments()
        while (p.matchIdentAhead("and")) {
            p.parseIdentRaw()
            p.skipWsAndComments()
            val right = parseNot(p, ids)
            left = CondNode.And(left, right)
            p.skipWsAndComments()
        }
        return left
    }

    private fun parseNot(p: Cursor, ids: List<String>): CondNode {
        p.skipWsAndComments()
        if (p.matchIdentAhead("not")) {
            p.parseIdentRaw()
            p.skipWsAndComments()
            return CondNode.Not(parseNot(p, ids))
        }
        return parsePrimary(p, ids)
    }

    private fun parsePrimary(p: Cursor, ids: List<String>): CondNode {
        p.skipWsAndComments()
        val c = p.peek() ?: throw YaraParseException("Unexpected end of condition")
        if (c == '(') {
            p.advance()
            val inner = parseOr(p, ids)
            p.skipWsAndComments()
            p.expectChar(')')
            return inner
        }
        if (c == '$') {
            p.advance()
            val id = "$" + p.parseIdentRaw()
            return CondNode.StrRef(id)
        }
        if (c.isDigit()) {
            // Only appears here as the N in "N of (...)"
            val n = p.parseNumberRaw().toInt()
            p.skipWsAndComments()
            p.expectIdent("of")
            p.skipWsAndComments()
            return parseOfTarget(p, OfCount.N(n), ids)
        }
        val word = p.parseIdentRaw()
        return when (word) {
            "true" -> CondNode.BoolLit(true)
            "false" -> CondNode.BoolLit(false)
            "any" -> { p.skipWsAndComments(); p.expectIdent("of"); p.skipWsAndComments(); parseOfTarget(p, OfCount.Any, ids) }
            "all" -> { p.skipWsAndComments(); p.expectIdent("of"); p.skipWsAndComments(); parseOfTarget(p, OfCount.All, ids) }
            "filesize" -> parseSizeCmp(p)
            else -> throw YaraParseException("Unsupported condition term '$word' (rule references and YARA-module fields aren't supported)")
        }
    }

    private fun parseOfTarget(p: Cursor, count: OfCount, ids: List<String>): CondNode {
        p.skipWsAndComments()
        if (p.matchIdentAhead("them")) {
            p.parseIdentRaw()
            return CondNode.OfThem(count, null)
        }
        p.expectChar('(')
        val list = mutableListOf<String>()
        p.skipWsAndComments()
        while (p.peek() != ')') {
            p.expectChar('$')
            val id = "$" + p.parseIdentRaw()
            if (p.peek() == '*') throw YaraParseException("Wildcard string-set references (\$a*) aren't supported")
            list.add(id)
            p.skipWsAndComments()
            if (p.peek() == ',') { p.advance(); p.skipWsAndComments() }
        }
        p.expectChar(')')
        return CondNode.OfThem(count, list)
    }

    private fun parseSizeCmp(p: Cursor): CondNode.SizeCmp {
        p.skipWsAndComments()
        val op = when {
            p.peekAhead("<=") -> { p.advanceN(2); "<=" }
            p.peekAhead(">=") -> { p.advanceN(2); ">=" }
            p.peekAhead("==") -> { p.advanceN(2); "==" }
            p.peek() == '<' -> { p.advance(); "<" }
            p.peek() == '>' -> { p.advance(); ">" }
            else -> throw YaraParseException("Expected comparison operator after 'filesize'")
        }
        p.skipWsAndComments()
        val numStr = p.parseNumberRaw()
        var multiplier = 1L
        if (p.peek()?.isLetter() == true) {
            when (val unit = p.parseIdentRaw().uppercase()) {
                "KB" -> multiplier = 1024L
                "MB" -> multiplier = 1024L * 1024L
                "GB" -> multiplier = 1024L * 1024L * 1024L
                "B" -> multiplier = 1L
                else -> throw YaraParseException("Unknown size unit '$unit'")
            }
        }
        val bytes = numStr.toLong() * multiplier
        return CondNode.SizeCmp(op, bytes)
    }

    // --- character-level cursor ---

    private class Cursor(private val s: String) {
        var pos = 0

        fun eof() = pos >= s.length
        fun peek(): Char? = if (pos < s.length) s[pos] else null
        fun peekAhead(literal: String): Boolean = s.regionMatches(pos, literal, 0, literal.length)
        fun advance(): Char? {
            if (pos >= s.length) return null
            return s[pos++]
        }
        fun advanceN(n: Int) { repeat(n) { advance() } }

        fun skipWsAndComments() {
            while (pos < s.length) {
                val c = s[pos]
                if (c.isWhitespace()) { pos++; continue }
                if (c == '/' && pos + 1 < s.length && s[pos + 1] == '/') {
                    while (pos < s.length && s[pos] != '\n') pos++
                    continue
                }
                if (c == '/' && pos + 1 < s.length && s[pos + 1] == '*') {
                    pos += 2
                    while (pos + 1 < s.length && !(s[pos] == '*' && s[pos + 1] == '/')) pos++
                    pos = minOf(pos + 2, s.length)
                    continue
                }
                break
            }
        }

        fun expectChar(c: Char) {
            skipWsAndComments()
            if (peek() != c) throw YaraParseException("Expected '$c' at offset $pos, found '${peek()}'")
            pos++
        }

        fun expectIdent(word: String) {
            skipWsAndComments()
            val id = parseIdentRaw()
            if (id != word) throw YaraParseException("Expected '$word' at offset $pos, found '$id'")
        }

        fun matchIdentAhead(word: String): Boolean {
            skipWsAndComments()
            val save = pos
            val ok = try { parseIdentRaw() == word } catch (e: Exception) { false }
            pos = save
            return ok
        }

        fun atSectionKeyword(): Boolean = matchIdentAhead("meta") || matchIdentAhead("strings") || matchIdentAhead("condition")

        fun parseIdentRaw(): String {
            skipWsAndComments()
            val start = pos
            if (pos >= s.length || !(s[pos].isLetter() || s[pos] == '_')) {
                throw YaraParseException("Expected identifier at offset $pos, found '${peek()}'")
            }
            while (pos < s.length && (s[pos].isLetterOrDigit() || s[pos] == '_')) pos++
            return s.substring(start, pos)
        }

        fun parseNumberRaw(): String {
            skipWsAndComments()
            val start = pos
            if (pos < s.length && s[pos] == '-') pos++
            while (pos < s.length && (s[pos].isDigit() || s[pos] == '.')) pos++
            if (pos == start) throw YaraParseException("Expected number at offset $pos")
            return s.substring(start, pos)
        }

        fun parseQuotedString(): String {
            expectChar('"')
            val sb = StringBuilder()
            while (true) {
                val c = advance() ?: throw YaraParseException("Unterminated string literal")
                if (c == '\\') {
                    val esc = advance() ?: throw YaraParseException("Unterminated escape in string literal")
                    sb.append(
                        when (esc) {
                            'n' -> '\n'; 't' -> '\t'; 'r' -> '\r'; '"' -> '"'; '\\' -> '\\'
                            'x' -> {
                                val hi = advance() ?: throw YaraParseException("Bad \\x escape")
                                val lo = advance() ?: throw YaraParseException("Bad \\x escape")
                                ((hexVal(hi) shl 4) or hexVal(lo)).toChar()
                            }
                            else -> esc
                        }
                    )
                    continue
                }
                if (c == '"') break
                sb.append(c)
            }
            return sb.toString()
        }

        fun skipToNextRuleKeyword(): Boolean {
            while (pos < s.length) {
                if (matchIdentAhead("rule")) return true
                pos++
            }
            return false
        }
    }
}

private fun Char.isHexDigit(): Boolean = this.isDigit() || this in 'a'..'f' || this in 'A'..'F'
private fun hexVal(c: Char): Int = when (c) {
    in '0'..'9' -> c - '0'
    in 'a'..'f' -> c - 'a' + 10
    in 'A'..'F' -> c - 'A' + 10
    else -> throw YaraParseException("Invalid hex digit '$c'")
}
app/src/main/java/org/wazuh/androidagent/yara/YaraEngine.kt 129 lines
package org.wazuh.androidagent.yara

/**
 * Matches parsed [YaraRule]s against a byte buffer. Byte/hex/regex search
 * uses an anchor-then-verify approach (find a candidate position on the
 * pattern's first non-wildcard byte, then verify the rest) rather than
 * naive O(n*m) scanning — fast in practice for the short signatures typical
 * of real-world YARA rules, without needing a Boyer-Moore table per pattern.
 * [YaraScanner] bounds the input size before calling this, since worst-case
 * behavior on a pathological pattern/input pair is still O(n*m).
 */
object YaraEngine {

    fun scan(rules: List<YaraRule>, content: ByteArray): List<YaraMatch> {
        val results = mutableListOf<YaraMatch>()
        // Decode once for regex patterns (ISO-8859-1 = 1 byte <-> 1 char, no lossy remapping).
        val asLatin1: String by lazy { String(content, Charsets.ISO_8859_1) }

        for (rule in rules) {
            val matchedIds = mutableSetOf<String>()
            for (s in rule.strings) {
                val hit = when (val pat = s.pattern) {
                    is YaraPattern.Text -> pat.variants.any { variant ->
                        findText(content, variant, pat.nocase, pat.fullword) >= 0
                    }
                    is YaraPattern.Hex -> findHex(content, pat.bytes, pat.mask) >= 0
                    is YaraPattern.Regex -> pat.pattern.containsMatchIn(asLatin1)
                }
                if (hit) matchedIds.add(s.id)
            }
            val allIds = rule.strings.map { it.id }
            if (evalCondition(rule.condition, matchedIds, allIds, content.size.toLong())) {
                results.add(YaraMatch(rule, allIds.filter { it in matchedIds }))
            }
        }
        return results
    }

    private fun evalCondition(node: CondNode, matched: Set<String>, allIds: List<String>, filesize: Long): Boolean =
        when (node) {
            is CondNode.StrRef -> node.id in matched
            is CondNode.BoolLit -> node.v
            is CondNode.Not -> !evalCondition(node.n, matched, allIds, filesize)
            is CondNode.And -> evalCondition(node.a, matched, allIds, filesize) && evalCondition(node.b, matched, allIds, filesize)
            is CondNode.Or -> evalCondition(node.a, matched, allIds, filesize) || evalCondition(node.b, matched, allIds, filesize)
            is CondNode.OfThem -> {
                val ids = node.ids ?: allIds
                val count = ids.count { it in matched }
                when (val c = node.count) {
                    OfCount.All -> count == ids.size && ids.isNotEmpty()
                    OfCount.Any -> count >= 1
                    is OfCount.N -> count >= c.n
                }
            }
            is CondNode.SizeCmp -> when (node.op) {
                "<" -> filesize < node.bytes
                ">" -> filesize > node.bytes
                "<=" -> filesize <= node.bytes
                ">=" -> filesize >= node.bytes
                "==" -> filesize == node.bytes
                else -> false
            }
        }

    private fun isWordByte(b: Byte): Boolean {
        val c = b.toInt().toChar()
        return c.isLetterOrDigit() || c == '_'
    }

    private fun foldByte(b: Byte): Byte {
        val c = b.toInt().toChar()
        return if (c in 'A'..'Z') (c + 32).code.toByte() else b
    }

    /** Returns the index of the first match, or -1. */
    fun findText(haystack: ByteArray, needle: ByteArray, nocase: Boolean, fullword: Boolean): Int {
        if (needle.isEmpty() || needle.size > haystack.size) return -1
        val n = haystack.size
        val m = needle.size
        val anchor = if (nocase) foldByte(needle[0]) else needle[0]
        var i = 0
        outer@ while (i <= n - m) {
            val hb = if (nocase) foldByte(haystack[i]) else haystack[i]
            if (hb == anchor) {
                var j = 1
                while (j < m) {
                    val a = if (nocase) foldByte(haystack[i + j]) else haystack[i + j]
                    val b = if (nocase) foldByte(needle[j]) else needle[j]
                    if (a != b) { i++; continue@outer }
                    j++
                }
                if (!fullword || isWordBoundaryMatch(haystack, i, m)) return i
            }
            i++
        }
        return -1
    }

    private fun isWordBoundaryMatch(haystack: ByteArray, start: Int, len: Int): Boolean {
        val beforeOk = start == 0 || !isWordByte(haystack[start - 1])
        val afterOk = start + len >= haystack.size || !isWordByte(haystack[start + len])
        return beforeOk && afterOk
    }

    /** Returns the index of the first match, or -1. mask[i]==true means that position is a '??' wildcard. */
    fun findHex(haystack: ByteArray, needle: ByteArray, mask: BooleanArray): Int {
        if (needle.isEmpty() || needle.size > haystack.size) return -1
        val n = haystack.size
        val m = needle.size
        var anchor = 0
        while (anchor < m && mask[anchor]) anchor++
        if (anchor == m) return 0 // fully wildcard pattern: matches trivially at position 0

        val anchorByte = needle[anchor]
        var i = 0
        outer@ while (i <= n - m) {
            if (haystack[i + anchor] == anchorByte) {
                var j = 0
                while (j < m) {
                    if (!mask[j] && haystack[i + j] != needle[j]) { i++; continue@outer }
                    j++
                }
                return i
            }
            i++
        }
        return -1
    }
}
app/src/main/java/org/wazuh/androidagent/yara/YaraScanner.kt 109 lines
package org.wazuh.androidagent.yara

import android.content.Context
import android.util.Log
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.io.File
import java.time.Instant

/**
 * Loads the bundled starter ruleset (assets/yara_rules/android_starter.yar)
 * plus any user-supplied rules from Settings, and scans a single APK's bytes
 * against them. Designed to be called on `app_installed` / `app_updated`
 * events (see AgentForegroundService) rather than as a continuous full-device
 * sweep, so the cost is bounded to "one APK, once, when it actually changes."
 */
class YaraScanner(private val context: Context) {

    private var cachedRules: List<YaraRule>? = null
    private var cachedCustomSource: String? = null

    /** (Re)loads rules if the custom-rules text has changed since last call; cheap to call every scan. */
    private fun rules(customRulesSource: String): List<YaraRule> {
        if (cachedRules != null && cachedCustomSource == customRulesSource) return cachedRules!!

        val combined = mutableListOf<YaraRule>()
        try {
            val bundled = context.assets.open(BUNDLED_RULES_ASSET).bufferedReader().use { it.readText() }
            combined.addAll(YaraParser.parseRulesLenient(bundled) { name, e ->
                Log.w(TAG, "Skipping bundled rule $name: ${e.message}")
            })
        } catch (e: Exception) {
            Log.e(TAG, "Failed to load bundled YARA rules", e)
        }

        if (customRulesSource.isNotBlank()) {
            combined.addAll(YaraParser.parseRulesLenient(customRulesSource) { name, e ->
                Log.w(TAG, "Skipping custom rule $name: ${e.message}")
            })
        }

        cachedRules = combined
        cachedCustomSource = customRulesSource
        return combined
    }

    /**
     * Scans [apkPath] and returns a "yara_match" LogEvent per matched rule (empty list if
     * clean, unreadable, or over [MAX_SCAN_BYTES]). Never throws — scan failures are logged
     * and treated as "no match" so one bad APK can't take the collector cycle down.
     */
    fun scanApk(packageName: String, apkPath: String?, customRulesSource: String): List<LogEvent> {
        if (apkPath.isNullOrBlank()) return emptyList()
        return try {
            val file = File(apkPath)
            if (!file.exists() || !file.canRead()) return emptyList()
            if (file.length() > MAX_SCAN_BYTES) {
                Log.i(TAG, "Skipping YARA scan of $packageName: APK is ${file.length()} bytes, over the ${MAX_SCAN_BYTES} cap")
                return emptyList()
            }
            val content = file.readBytes()
            val ruleSet = rules(customRulesSource)
            val matches = YaraEngine.scan(ruleSet, content)
            matches.map { toEvent(packageName, it) }
        } catch (e: Exception) {
            Log.e(TAG, "YARA scan of $packageName failed", e)
            emptyList()
        }
    }

    private fun toEvent(packageName: String, match: YaraMatch): LogEvent {
        return LogEvent(
            timestamp = Instant.now(),
            msgId = "yara_match",
            severity = severityFor(match.rule.meta["severity"]),
            fields = mapOf(
                "package_name" to packageName,
                "rule_name" to match.rule.name,
                "rule_tags" to match.rule.tags,
                "rule_description" to (match.rule.meta["description"] ?: ""),
                "matched_strings" to match.matchedStringIds
            )
        )
    }

    /**
     * Maps a rule's own `meta.severity` string (a convention this app's starter
     * ruleset uses, not a real YARA feature) to our syslog-style Severity scale,
     * so a rule that's explicitly informational (e.g. the EICAR self-test)
     * doesn't get reported as critical just because it's a "YARA match."
     * Unrecognized/missing values default to a cautious WARNING rather than
     * the two extremes, since an unlabeled rule's real severity is unknown.
     */
    private fun severityFor(metaSeverity: String?): Int = when (metaSeverity?.lowercase()) {
        "critical" -> Severity.EMERGENCY
        "high" -> Severity.CRITICAL
        "medium" -> Severity.WARNING
        "low" -> Severity.NOTICE
        "info" -> Severity.INFO
        else -> Severity.WARNING
    }

    companion object {
        private const val TAG = "YaraScanner"
        private const val BUNDLED_RULES_ASSET = "yara_rules/android_starter.yar"
        /** Skip scanning APKs larger than this — bounds worst-case scan time/memory on-device. */
        const val MAX_SCAN_BYTES = 150L * 1024 * 1024
    }
}

UI

The three screens: main settings/enrollment, Live Log, and AI Analysis.

app/src/main/java/org/wazuh/androidagent/ui/MainActivity.kt 349 lines
package org.wazuh.androidagent.ui

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.activity.result.contract.ActivityResultContracts
import org.wazuh.androidagent.AgentForegroundService
import org.wazuh.androidagent.R
import org.wazuh.androidagent.SettingsStore
import org.wazuh.androidagent.TransportMode
import org.wazuh.androidagent.net.TlsUtil
import org.wazuh.androidagent.wazuhagent.AuthdClient

class MainActivity : AppCompatActivity() {

    private lateinit var settings: SettingsStore

    private lateinit var hostInput: EditText
    private lateinit var portInput: EditText
    private lateinit var deviceLabelInput: EditText
    private lateinit var tlsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var trustAllSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var customCaInput: EditText
    private lateinit var deviceInfoSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var installedAppsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var networkStateSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var batterySwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var notificationsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var notificationTextSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var yaraScanSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var customYaraRulesInput: EditText
    private lateinit var fullSystemLogsSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var systemLogPermissionStatusText: TextView
    private lateinit var statusText: TextView
    private lateinit var startStopButton: Button

    private lateinit var agentProtocolSwitch: com.google.android.material.switchmaterial.SwitchMaterial
    private lateinit var enrollmentStatusText: TextView
    private lateinit var authdPortInput: EditText
    private lateinit var agentPortInput: EditText
    private lateinit var enrollPasswordInput: EditText
    private lateinit var enrollButton: Button

    private var boundService: AgentForegroundService? = null
    private var bound = false
    private val statusHandler = Handler(Looper.getMainLooper())
    private val mainHandler = Handler(Looper.getMainLooper())

    private val notificationPermissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { }

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            boundService = (service as AgentForegroundService.LocalBinder).getService()
            bound = true
            refreshStatus()
        }

        override fun onServiceDisconnected(name: ComponentName?) {
            boundService = null
            bound = false
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        settings = SettingsStore(applicationContext)

        hostInput = findViewById(R.id.hostInput)
        portInput = findViewById(R.id.portInput)
        deviceLabelInput = findViewById(R.id.deviceLabelInput)
        tlsSwitch = findViewById(R.id.tlsSwitch)
        trustAllSwitch = findViewById(R.id.trustAllSwitch)
        customCaInput = findViewById(R.id.customCaInput)
        deviceInfoSwitch = findViewById(R.id.deviceInfoSwitch)
        installedAppsSwitch = findViewById(R.id.installedAppsSwitch)
        networkStateSwitch = findViewById(R.id.networkStateSwitch)
        batterySwitch = findViewById(R.id.batterySwitch)
        notificationsSwitch = findViewById(R.id.notificationsSwitch)
        notificationTextSwitch = findViewById(R.id.notificationTextSwitch)
        yaraScanSwitch = findViewById(R.id.yaraScanSwitch)
        customYaraRulesInput = findViewById(R.id.customYaraRulesInput)
        fullSystemLogsSwitch = findViewById(R.id.fullSystemLogsSwitch)
        systemLogPermissionStatusText = findViewById(R.id.systemLogPermissionStatusText)
        statusText = findViewById(R.id.statusText)
        startStopButton = findViewById(R.id.startStopButton)

        agentProtocolSwitch = findViewById(R.id.agentProtocolSwitch)
        enrollmentStatusText = findViewById(R.id.enrollmentStatusText)
        authdPortInput = findViewById(R.id.authdPortInput)
        agentPortInput = findViewById(R.id.agentPortInput)
        enrollPasswordInput = findViewById(R.id.enrollPasswordInput)
        enrollButton = findViewById(R.id.enrollButton)

        loadSettingsIntoUi()
        refreshSystemLogPermissionStatus()

        startStopButton.setOnClickListener { onStartStopClicked() }
        enrollButton.setOnClickListener { onEnrollClicked() }
        findViewById<Button>(R.id.testEventButton).setOnClickListener {
            saveUiIntoSettings()
            boundService?.enqueueTestEvent()
            Toast.makeText(this, "Test event queued", Toast.LENGTH_SHORT).show()
        }
        findViewById<Button>(R.id.grantNotificationAccessButton).setOnClickListener {
            startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
        }
        findViewById<Button>(R.id.liveLogButton).setOnClickListener {
            startActivity(Intent(this, LiveLogActivity::class.java))
        }
        findViewById<Button>(R.id.aiAnalysisButton).setOnClickListener {
            startActivity(Intent(this, AiAnalysisActivity::class.java))
        }

        // Category toggles take effect immediately (the service re-reads Settings
        // on every collection tick / receiver callback), so persist as soon as
        // the user flips one rather than waiting for another save point.
        val toggleListener = { _: android.widget.CompoundButton, _: Boolean -> saveUiIntoSettings() }
        deviceInfoSwitch.setOnCheckedChangeListener(toggleListener)
        installedAppsSwitch.setOnCheckedChangeListener(toggleListener)
        networkStateSwitch.setOnCheckedChangeListener(toggleListener)
        batterySwitch.setOnCheckedChangeListener(toggleListener)
        notificationsSwitch.setOnCheckedChangeListener(toggleListener)
        notificationTextSwitch.setOnCheckedChangeListener(toggleListener)
        yaraScanSwitch.setOnCheckedChangeListener(toggleListener)
        fullSystemLogsSwitch.setOnCheckedChangeListener(toggleListener)

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
        }

        if (settings.agentEnabled) {
            bindToService()
        }
    }

    override fun onResume() {
        super.onResume()
        statusHandler.post(statusUpdater)
        // Re-checked every time the screen becomes visible (not just onCreate) since
        // the only way this permission changes is the user running an adb command
        // while the app is backgrounded, then switching back to it.
        refreshSystemLogPermissionStatus()
    }

    override fun onPause() {
        super.onPause()
        statusHandler.removeCallbacks(statusUpdater)
        // trustAllSwitch/tlsSwitch/customCaInput (and host/port/label) have no
        // per-change listener like the category toggles do — without this,
        // navigating away (e.g. to AiAnalysisActivity) right after flipping one
        // silently drops it, since it was never on a save path.
        saveUiIntoSettings()
    }

    override fun onDestroy() {
        super.onDestroy()
        if (bound) {
            unbindService(connection)
            bound = false
        }
    }

    private val statusUpdater = object : Runnable {
        override fun run() {
            refreshStatus()
            statusHandler.postDelayed(this, 2000)
        }
    }

    private fun loadSettingsIntoUi() {
        hostInput.setText(settings.managerHost)
        portInput.setText(settings.managerPort.toString())
        deviceLabelInput.setText(settings.deviceLabel)
        tlsSwitch.isChecked = settings.useTls
        trustAllSwitch.isChecked = settings.trustAllCerts
        customCaInput.setText(settings.customCaPem)
        deviceInfoSwitch.isChecked = settings.collectDeviceInfo
        installedAppsSwitch.isChecked = settings.collectInstalledApps
        networkStateSwitch.isChecked = settings.collectNetworkState
        batterySwitch.isChecked = settings.collectBattery
        notificationsSwitch.isChecked = settings.collectNotifications
        notificationTextSwitch.isChecked = settings.includeNotificationText
        yaraScanSwitch.isChecked = settings.collectYaraScan
        customYaraRulesInput.setText(settings.customYaraRules)
        fullSystemLogsSwitch.isChecked = settings.collectFullSystemLogs
        startStopButton.text = if (settings.agentEnabled) "Stop agent" else "Start agent"

        agentProtocolSwitch.isChecked = settings.transportMode == TransportMode.WAZUH_AGENT
        authdPortInput.setText(settings.authdPort.toString())
        agentPortInput.setText(settings.agentProtocolPort.toString())
        enrollPasswordInput.setText(settings.enrollmentPassword)
        updateEnrollmentStatusText()
    }

    private fun saveUiIntoSettings() {
        settings.managerHost = hostInput.text.toString().trim()
        settings.managerPort = portInput.text.toString().trim().toIntOrNull() ?: 6514
        settings.deviceLabel = deviceLabelInput.text.toString().trim()
        settings.useTls = tlsSwitch.isChecked
        settings.trustAllCerts = trustAllSwitch.isChecked
        settings.customCaPem = customCaInput.text.toString().trim()
        settings.collectDeviceInfo = deviceInfoSwitch.isChecked
        settings.collectInstalledApps = installedAppsSwitch.isChecked
        settings.collectNetworkState = networkStateSwitch.isChecked
        settings.collectBattery = batterySwitch.isChecked
        settings.collectNotifications = notificationsSwitch.isChecked
        settings.includeNotificationText = notificationTextSwitch.isChecked
        settings.collectYaraScan = yaraScanSwitch.isChecked
        settings.customYaraRules = customYaraRulesInput.text.toString()
        settings.collectFullSystemLogs = fullSystemLogsSwitch.isChecked

        settings.transportMode = if (agentProtocolSwitch.isChecked) TransportMode.WAZUH_AGENT else TransportMode.SYSLOG
        settings.authdPort = authdPortInput.text.toString().trim().toIntOrNull() ?: 1515
        settings.agentProtocolPort = agentPortInput.text.toString().trim().toIntOrNull() ?: 1514
        settings.enrollmentPassword = enrollPasswordInput.text.toString()
    }

    /**
     * READ_LOGS can only ever be granted out-of-band via `adb shell pm grant`, so
     * this is purely informational — there is no button here that could request
     * it, unlike every other permission this app uses.
     */
    private fun refreshSystemLogPermissionStatus() {
        val granted = ContextCompat.checkSelfPermission(
            this, android.Manifest.permission.READ_LOGS
        ) == android.content.pm.PackageManager.PERMISSION_GRANTED
        systemLogPermissionStatusText.text = if (granted) {
            "READ_LOGS permission: granted"
        } else {
            "READ_LOGS permission: not granted (see website for the adb command)"
        }
    }

    private fun updateEnrollmentStatusText() {
        enrollmentStatusText.text = if (settings.isEnrolled()) {
            "Enrolled as '${settings.agentName}' (id ${settings.agentId})"
        } else {
            "Not enrolled"
        }
    }

    private fun onEnrollClicked() {
        saveUiIntoSettings()
        val host = settings.managerHost
        if (host.isBlank()) {
            Toast.makeText(this, "Enter a manager host first", Toast.LENGTH_LONG).show()
            return
        }
        val deviceName = settings.deviceLabel.ifBlank {
            "android-${Build.MODEL}-${System.currentTimeMillis() % 100000}"
        }.replace(Regex("[^A-Za-z0-9._-]"), "_")

        enrollButton.isEnabled = false
        enrollmentStatusText.text = "Enrolling…"

        Thread {
            try {
                val factory = TlsUtil.buildSocketFactory(
                    settings.customCaPem.ifBlank { null },
                    settings.trustAllCerts
                )
                val result = AuthdClient.enroll(
                    host = host,
                    port = settings.authdPort,
                    agentName = deviceName,
                    password = settings.enrollmentPassword.ifBlank { null },
                    sslSocketFactory = factory
                )
                settings.agentId = result.agentId
                settings.agentName = result.agentName
                settings.agentKey = result.agentKey
                mainHandler.post {
                    enrollButton.isEnabled = true
                    updateEnrollmentStatusText()
                    Toast.makeText(this, "Enrolled: ${result.agentName} (${result.agentId})", Toast.LENGTH_LONG).show()
                }
            } catch (e: Exception) {
                mainHandler.post {
                    enrollButton.isEnabled = true
                    enrollmentStatusText.text = "Enrollment failed: ${e.message}"
                    Toast.makeText(this, "Enrollment failed: ${e.message}", Toast.LENGTH_LONG).show()
                }
            }
        }.start()
    }

    private fun onStartStopClicked() {
        saveUiIntoSettings()
        if (!settings.agentEnabled) {
            val ready = if (settings.transportMode == TransportMode.WAZUH_AGENT) {
                settings.managerHost.isNotBlank() && settings.isEnrolled()
            } else {
                settings.isConfigured()
            }
            if (!ready) {
                val msg = if (settings.transportMode == TransportMode.WAZUH_AGENT)
                    "Enroll with the manager first" else "Enter a manager host and valid port first"
                Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
                return
            }
            settings.agentEnabled = true
            val intent = Intent(this, AgentForegroundService::class.java)
            ContextCompat.startForegroundService(this, intent)
            bindToService()
        } else {
            settings.agentEnabled = false
            boundService?.let {
                if (bound) {
                    unbindService(connection)
                    bound = false
                }
            }
            stopService(Intent(this, AgentForegroundService::class.java))
        }
        startStopButton.text = if (settings.agentEnabled) "Stop agent" else "Start agent"
        refreshStatus()
    }

    private fun bindToService() {
        bindService(Intent(this, AgentForegroundService::class.java), connection, Context.BIND_AUTO_CREATE)
    }

    private fun refreshStatus() {
        val svc = boundService
        statusText.text = if (!settings.agentEnabled) {
            "Agent stopped"
        } else if (svc == null) {
            "Agent starting…"
        } else {
            val connState = if (svc.isConnected()) "connected" else "reconnecting"
            "Agent running — $connState, queued=${svc.queueSize()}, sent=${svc.sentCount()}"
        }
    }
}
app/src/main/java/org/wazuh/androidagent/ui/LiveLogActivity.kt 77 lines
package org.wazuh.androidagent.ui

import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.wazuh.androidagent.R
import org.wazuh.androidagent.net.LiveLogBus
import org.wazuh.androidagent.net.LogEvent

/**
 * Live, in-app tail of every event the agent produces, newest at the bottom
 * (like a terminal), auto-scrolling as new events arrive. Purely local — it
 * reads from [LiveLogBus], not the network — so it works whether or not the
 * manager connection is currently up.
 */
class LiveLogActivity : AppCompatActivity() {

    private lateinit var recyclerView: RecyclerView
    private lateinit var emptyText: TextView
    private lateinit var adapter: LiveLogAdapter
    private var autoScroll = true

    private val listener: (LogEvent) -> Unit = { event ->
        val pos = adapter.append(event)
        emptyText.visibility = android.view.View.GONE
        if (pos >= 0) {
            adapter.notifyItemInserted(pos)
        } else {
            // Oldest row was dropped to stay under the cap; simplest correct
            // refresh for that case is a full rebind.
            adapter.notifyDataSetChanged()
        }
        if (autoScroll) recyclerView.scrollToPosition(adapter.itemCount - 1)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_live_log)

        recyclerView = findViewById(R.id.liveLogRecyclerView)
        emptyText = findViewById(R.id.liveLogEmptyText)
        adapter = LiveLogAdapter()
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter

        val initial = LiveLogBus.recent()
        adapter.replaceAll(initial)
        emptyText.visibility = if (initial.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE
        if (initial.isNotEmpty()) recyclerView.scrollToPosition(adapter.itemCount - 1)

        val autoScrollButton = findViewById<Button>(R.id.autoScrollToggleButton)
        autoScrollButton.text = "Auto-scroll: ON"
        autoScrollButton.setOnClickListener {
            autoScroll = !autoScroll
            autoScrollButton.text = if (autoScroll) "Auto-scroll: ON" else "Auto-scroll: OFF"
            if (autoScroll) recyclerView.scrollToPosition(adapter.itemCount - 1)
        }

        findViewById<Button>(R.id.clearLiveLogButton).setOnClickListener {
            adapter.clear()
            emptyText.visibility = android.view.View.VISIBLE
        }
    }

    override fun onStart() {
        super.onStart()
        LiveLogBus.addListener(listener)
    }

    override fun onStop() {
        super.onStop()
        LiveLogBus.removeListener(listener)
    }
}
app/src/main/java/org/wazuh/androidagent/ui/LiveLogAdapter.kt 74 lines
package org.wazuh.androidagent.ui

import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.Severity
import java.time.format.DateTimeFormatter
import java.time.ZoneId

/** Renders one [LogEvent] per row: timestamp, event type, severity-colored, and a compact field dump. */
class LiveLogAdapter(private val events: MutableList<LogEvent> = mutableListOf()) :
    RecyclerView.Adapter<LiveLogAdapter.ViewHolder>() {

    class ViewHolder(view: TextView) : RecyclerView.ViewHolder(view) {
        val text: TextView = view
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val tv = LayoutInflater.from(parent.context)
            .inflate(android.R.layout.simple_list_item_1, parent, false) as TextView
        tv.typeface = android.graphics.Typeface.MONOSPACE
        tv.textSize = 12f
        tv.setPadding(24, 12, 24, 12)
        return ViewHolder(tv)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val event = events[position]
        val time = TIME_FMT.format(event.timestamp.atZone(ZoneId.systemDefault()))
        val fieldsPreview = event.fields.entries.joinToString(", ") { (k, v) -> "$k=$v" }
        holder.text.text = "$time  [${event.msgId}]\n$fieldsPreview"
        holder.text.setTextColor(colorFor(event.severity))
    }

    override fun getItemCount(): Int = events.size

    /** Appends and returns the new item's position, or null if it was dropped to stay under the cap. */
    fun append(event: LogEvent): Int {
        events.add(event)
        val dropped = if (events.size > MAX_ROWS) {
            events.removeAt(0)
            true
        } else false
        return if (dropped) -1 else events.size - 1
    }

    fun replaceAll(newEvents: List<LogEvent>) {
        events.clear()
        events.addAll(newEvents.takeLast(MAX_ROWS))
        notifyDataSetChanged()
    }

    fun clear() {
        events.clear()
        notifyDataSetChanged()
    }

    private fun colorFor(severity: Int): Int = when {
        severity <= Severity.ERROR -> Color.parseColor("#D32F2F")
        severity == Severity.WARNING -> Color.parseColor("#F57C00")
        severity == Severity.NOTICE -> Color.parseColor("#1976D2")
        severity == Severity.DEBUG -> Color.parseColor("#9E9E9E")
        else -> Color.parseColor("#1976D2") // INFO and anything else - was near-black (#212121), now blue per request
    }

    companion object {
        private const val MAX_ROWS = 500
        private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss")
    }
}
app/src/main/java/org/wazuh/androidagent/ui/AiAnalysisActivity.kt 203 lines
package org.wazuh.androidagent.ui

import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import org.wazuh.androidagent.R
import org.wazuh.androidagent.SettingsStore
import org.wazuh.androidagent.claude.ClaudeClient
import org.wazuh.androidagent.wazuhapi.WazuhApiClient

/**
 * On-demand screen: authenticates to the Wazuh manager's REST API, fetches
 * recent `/manager/logs` entries, and sends them to Claude for analysis.
 * Separate from [AgentForegroundService][org.wazuh.androidagent.AgentForegroundService] —
 * nothing here runs unless the user taps the button.
 */
class AiAnalysisActivity : AppCompatActivity() {

    private lateinit var settings: SettingsStore

    private lateinit var apiHostInput: EditText
    private lateinit var apiPortInput: EditText
    private lateinit var apiUsernameInput: EditText
    private lateinit var apiPasswordInput: EditText
    private lateinit var logFetchLimitInput: EditText
    private lateinit var claudeApiKeyInput: EditText
    private lateinit var claudeModelInput: EditText
    private lateinit var fetchAndAnalyzeButton: Button
    private lateinit var analysisStatusText: TextView
    private lateinit var analysisResultText: TextView
    private lateinit var rawLogsText: TextView

    private val mainHandler = Handler(Looper.getMainLooper())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_ai_analysis)
        settings = SettingsStore(applicationContext)

        apiHostInput = findViewById(R.id.apiHostInput)
        apiPortInput = findViewById(R.id.apiPortInput)
        apiUsernameInput = findViewById(R.id.apiUsernameInput)
        apiPasswordInput = findViewById(R.id.apiPasswordInput)
        logFetchLimitInput = findViewById(R.id.logFetchLimitInput)
        claudeApiKeyInput = findViewById(R.id.claudeApiKeyInput)
        claudeModelInput = findViewById(R.id.claudeModelInput)
        fetchAndAnalyzeButton = findViewById(R.id.fetchAndAnalyzeButton)
        analysisStatusText = findViewById(R.id.analysisStatusText)
        analysisResultText = findViewById(R.id.analysisResultText)
        rawLogsText = findViewById(R.id.rawLogsText)

        loadSettingsIntoUi()
        fetchAndAnalyzeButton.setOnClickListener { onFetchAndAnalyzeClicked() }
    }

    override fun onPause() {
        super.onPause()
        saveUiIntoSettings()
    }

    private fun loadSettingsIntoUi() {
        apiHostInput.setText(settings.wazuhApiHost.ifBlank { settings.managerHost })
        apiPortInput.setText(settings.wazuhApiPort.toString())
        apiUsernameInput.setText(settings.wazuhApiUsername)
        apiPasswordInput.setText(settings.wazuhApiPassword)
        logFetchLimitInput.setText(settings.logFetchLimit.toString())
        claudeApiKeyInput.setText(settings.claudeApiKey)
        claudeModelInput.setText(settings.claudeModel)
    }

    private fun saveUiIntoSettings() {
        settings.wazuhApiHost = apiHostInput.text.toString().trim()
        settings.wazuhApiPort = apiPortInput.text.toString().trim().toIntOrNull() ?: 55000
        settings.wazuhApiUsername = apiUsernameInput.text.toString().trim()
        settings.wazuhApiPassword = apiPasswordInput.text.toString()
        settings.logFetchLimit = logFetchLimitInput.text.toString().trim().toIntOrNull()?.coerceIn(1, 5000) ?: 200
        settings.claudeApiKey = claudeApiKeyInput.text.toString().trim()
        settings.claudeModel = claudeModelInput.text.toString().trim().ifBlank { "claude-opus-5" }
    }

    private fun onFetchAndAnalyzeClicked() {
        saveUiIntoSettings()

        if (!settings.isAiAnalysisConfigured()) {
            Toast.makeText(this, "Fill in the Wazuh API and Claude fields first", Toast.LENGTH_LONG).show()
            return
        }

        fetchAndAnalyzeButton.isEnabled = false
        analysisStatusText.text = "Authenticating to Wazuh API…"
        analysisResultText.text = ""
        rawLogsText.text = ""

        val host = settings.wazuhApiHost
        val port = settings.wazuhApiPort
        val username = settings.wazuhApiUsername
        val password = settings.wazuhApiPassword
        val limit = settings.logFetchLimit
        val apiKey = settings.claudeApiKey
        val model = settings.claudeModel
        val trustAll = settings.trustAllCerts
        val customCa = settings.customCaPem.ifBlank { null }

        // This runs on a plain background Thread, not a foreground service —
        // if the screen turns off mid-request, Doze/App Standby (or an OEM's
        // more aggressive background killer) can freeze it with no exception
        // ever thrown, which looks exactly like "fetch works, then Claude
        // silently never responds even after minutes". A short partial wake
        // lock keeps the CPU (and this thread) alive for the request's
        // lifetime regardless of screen state; WAKE_LOCK is already granted
        // in the manifest for the agent service. Timeout is a safety net
        // slightly above ClaudeClient's own connect+read timeout ceiling, in
        // case release() is ever skipped by an unexpected code path.
        val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
            .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WazuhMobileAgent:AiAnalysis")
        wakeLock.acquire(4 * 60 * 1000L)

        Thread {
            try {
                val token = WazuhApiClient.authenticate(host, port, username, password, trustAll, customCa)

                setStatus("Fetching last $limit manager log entries…")
                val entries = WazuhApiClient.fetchManagerLogs(host, port, token, limit, trustAll, customCa)
                val rawText = if (entries.isEmpty()) {
                    "(no log entries returned)"
                } else {
                    entries.joinToString("\n")
                }
                mainHandler.post { rawLogsText.text = rawText }

                if (entries.isEmpty()) {
                    setStatus("No log entries to analyze.")
                    mainHandler.post {
                        analysisResultText.text = "Nothing to analyze — the manager returned no log entries."
                        fetchAndAnalyzeButton.isEnabled = true
                    }
                    return@Thread
                }

                setStatus("Sending ${entries.size} entries to Claude ($model)…")
                val analysis = ClaudeClient.analyze(
                    apiKey = apiKey,
                    model = model,
                    systemPrompt = SYSTEM_PROMPT,
                    userPrompt = buildUserPrompt(entries)
                )

                mainHandler.post {
                    analysisResultText.text = analysis
                    analysisStatusText.text = "Done — analyzed ${entries.size} log entries."
                    fetchAndAnalyzeButton.isEnabled = true
                }
            } catch (e: Exception) {
                mainHandler.post {
                    analysisStatusText.text = "Failed: ${e.message}"
                    fetchAndAnalyzeButton.isEnabled = true
                    Toast.makeText(this, "Failed: ${e.message}", Toast.LENGTH_LONG).show()
                }
            } finally {
                if (wakeLock.isHeld) wakeLock.release()
            }
        }.start()
    }

    private fun setStatus(text: String) {
        mainHandler.post { analysisStatusText.text = text }
    }

    private fun buildUserPrompt(entries: List<WazuhApiClient.LogEntry>): String {
        val header = "Below are ${entries.size} recent log entries from a Wazuh manager's /manager/logs API " +
            "(the manager's own ossec.log, not indexed agent alerts), newest first.\n\n"
        return header + entries.joinToString("\n") { it.toString() }
    }

    companion object {
        private const val SYSTEM_PROMPT = """
You are a security analyst reviewing Wazuh SIEM manager log entries for an
Android companion app's user. Analyze the provided log entries and produce a
concise report covering:

1. Overall health/status of the manager (any errors, connection issues,
   rule/decoder problems, agent connectivity issues).
2. Any security-relevant findings: authentication failures, unusual agent
   behavior, configuration problems, or anything indicating a misconfigured
   or compromised environment.
3. Notable patterns or repeated entries worth the user's attention.
4. A short prioritized list of recommended next steps, if any are warranted.

If nothing concerning is present, say so plainly rather than manufacturing
findings. Base every claim strictly on the provided log text — do not invent
timestamps, hosts, or events not present in the input. Keep the report
readable on a phone screen: use short headings and bullet points, not long
paragraphs.
"""
    }
}

Utilities & tests

Crash capture, and the pure-JVM test suite for the YARA engine (no device required).

app/src/main/java/org/wazuh/androidagent/util/CrashCatcher.kt 42 lines
package org.wazuh.androidagent.util

import org.wazuh.androidagent.net.LogEvent
import org.wazuh.androidagent.net.LogQueue
import org.wazuh.androidagent.net.Severity
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant

/**
 * Captures this app's own uncaught exceptions as a security-relevant event
 * before handing off to the platform's default handler (so the app still
 * crashes/reports normally). Android does not allow a non-system app to
 * observe crashes of *other* apps.
 */
object CrashCatcher {
    fun install(queue: LogQueue) {
        val previous = Thread.getDefaultUncaughtExceptionHandler()
        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
            try {
                val sw = StringWriter()
                throwable.printStackTrace(PrintWriter(sw))
                queue.enqueue(
                    LogEvent(
                        timestamp = Instant.now(),
                        msgId = "agent_crash",
                        severity = Severity.CRITICAL,
                        fields = mapOf(
                            "thread" to thread.name,
                            "exception" to throwable.toString(),
                            "stack_trace" to sw.toString().take(4000)
                        )
                    )
                )
            } catch (ignored: Exception) {
                // Never let telemetry capture itself crash the crash handler.
            } finally {
                previous?.uncaughtException(thread, throwable)
            }
        }
    }
}
app/src/test/java/org/wazuh/androidagent/yara/YaraEngineTest.kt 326 lines
package org.wazuh.androidagent.yara

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Test
import java.io.File

/**
 * Pure-JVM tests for the parser + matching engine (no Android framework
 * dependency) — run via `gradle testDebugUnitTest`, no device/emulator
 * needed. This is the actual verification for the YARA subset, since there's
 * no device available in this environment to test the compiled app on.
 */
class YaraEngineTest {

    @Test
    fun `simple ascii string match fires condition`() {
        val src = """
            rule Simple {
                strings:
                    ${'$'}a = "malicious_marker"
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertEquals(1, rules.size)
        val hit = YaraEngine.scan(rules, "xxx malicious_marker xxx".toByteArray())
        assertEquals(1, hit.size)
        assertEquals("Simple", hit[0].rule.name)

        val miss = YaraEngine.scan(rules, "nothing interesting here".toByteArray())
        assertTrue(miss.isEmpty())
    }

    @Test
    fun `nocase modifier matches regardless of case`() {
        val src = """
            rule CI {
                strings:
                    ${'$'}a = "EVIL" nocase
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "this is evil stuff".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "this is EvIl stuff".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "this is fine".toByteArray()).isEmpty())
    }

    @Test
    fun `fullword modifier rejects substring-only matches`() {
        val src = """
            rule FW {
                strings:
                    ${'$'}a = "cat" fullword
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "a cat sat".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "concatenate".toByteArray()).isEmpty())
    }

    @Test
    fun `hex pattern with wildcard matches`() {
        val src = """
            rule Hex {
                strings:
                    ${'$'}a = { 4D 5A ?? ?? 90 00 }
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        val content = byteArrayOf(0x00, 0x4D, 0x5A, 0x11.toByte(), 0x22, 0x90.toByte(), 0x00, 0x00)
        assertTrue(YaraEngine.scan(rules, content).isNotEmpty())
        val noMatch = byteArrayOf(0x4D, 0x5A, 0x11.toByte(), 0x22, 0x91.toByte(), 0x00)
        assertTrue(YaraEngine.scan(rules, noMatch).isEmpty())
    }

    @Test
    fun `any of them requires at least one`() {
        val src = """
            rule AnyOf {
                strings:
                    ${'$'}a = "aaa"
                    ${'$'}b = "bbb"
                    ${'$'}c = "ccc"
                condition:
                    any of them
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "bbb only".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "none of it".toByteArray()).isEmpty())
    }

    @Test
    fun `all of them requires every string`() {
        val src = """
            rule AllOf {
                strings:
                    ${'$'}a = "aaa"
                    ${'$'}b = "bbb"
                condition:
                    all of them
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "aaa bbb".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "aaa only".toByteArray()).isEmpty())
    }

    @Test
    fun `N of explicit set`() {
        val src = """
            rule NOf {
                strings:
                    ${'$'}a = "aaa"
                    ${'$'}b = "bbb"
                    ${'$'}c = "ccc"
                condition:
                    2 of (${'$'}a, ${'$'}b, ${'$'}c)
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "aaa bbb".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "aaa only".toByteArray()).isEmpty())
    }

    @Test
    fun `boolean and or not composition`() {
        val src = """
            rule Bool {
                strings:
                    ${'$'}a = "aaa"
                    ${'$'}b = "bbb"
                condition:
                    ${'$'}a and not ${'$'}b
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "aaa only".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "aaa bbb".toByteArray()).isEmpty())
    }

    @Test
    fun `filesize comparison`() {
        val src = """
            rule Small {
                condition:
                    filesize < 10
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, ByteArray(5)).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, ByteArray(20)).isEmpty())
    }

    @Test
    fun `regex pattern matches`() {
        val src = """
            rule Rx {
                strings:
                    ${'$'}a = /ev[i1]l[0-9]+/ nocase
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertTrue(YaraEngine.scan(rules, "prefix EV1L42 suffix".toByteArray()).isNotEmpty())
        assertTrue(YaraEngine.scan(rules, "nothing here".toByteArray()).isEmpty())
    }

    @Test
    fun `meta section is parsed but does not affect matching`() {
        val src = """
            rule WithMeta {
                meta:
                    description = "test rule"
                    severity = "high"
                strings:
                    ${'$'}a = "trigger"
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertEquals("test rule", rules[0].meta["description"])
        assertEquals("high", rules[0].meta["severity"])
        assertTrue(YaraEngine.scan(rules, "trigger".toByteArray()).isNotEmpty())
    }

    @Test
    fun `multiple rules in one file parse independently`() {
        val src = """
            rule First {
                strings:
                    ${'$'}a = "one"
                condition:
                    ${'$'}a
            }

            rule Second {
                strings:
                    ${'$'}a = "two"
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertEquals(2, rules.size)
        val hits = YaraEngine.scan(rules, "one two".toByteArray())
        assertEquals(2, hits.size)
    }

    @Test
    fun `lenient parsing skips a malformed rule but keeps the rest`() {
        val src = """
            rule Good1 {
                strings:
                    ${'$'}a = "x"
                condition:
                    ${'$'}a
            }

            rule Broken {
                condition:
                    pe.imports("nope")
            }

            rule Good2 {
                strings:
                    ${'$'}a = "y"
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val errors = mutableListOf<String>()
        val rules = YaraParser.parseRulesLenient(src) { name, e -> errors.add(name) }
        assertEquals(2, rules.size)
        assertEquals(listOf("Good1", "Good2"), rules.map { it.name })
        assertTrue(errors.isNotEmpty())
    }

    @Test
    fun `tags after rule name are parsed`() {
        val src = """
            rule Tagged : malware android {
                strings:
                    ${'$'}a = "x"
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        assertEquals(listOf("malware", "android"), rules[0].tags)
    }

    @Test(expected = YaraParseException::class)
    fun `unsupported module reference throws instead of silently matching everything`() {
        val src = """
            rule Bad {
                condition:
                    pe.number_of_sections > 2
            }
        """.trimIndent()
        YaraParser.parseRules(src)
    }

    @Test
    fun `wide string variant matches utf16le content`() {
        val src = """
            rule Wide {
                strings:
                    ${'$'}a = "hi" wide
                condition:
                    ${'$'}a
            }
        """.trimIndent()
        val rules = YaraParser.parseRules(src)
        val utf16le = byteArrayOf('h'.code.toByte(), 0, 'i'.code.toByte(), 0)
        assertTrue(YaraEngine.scan(rules, utf16le).isNotEmpty())
        assertFalse(YaraEngine.scan(rules, "hi".toByteArray()).isNotEmpty()) // ascii-only content, wide-only pattern
    }

    /**
     * Loads the ACTUAL bundled asset (not a copy) to catch drift between this
     * test and what ships in the app. Fails loudly (not silently-empty) if
     * every rule in the file fails to parse, since that would mean the
     * bundled ruleset is dead on arrival.
     */
    @Test
    fun `bundled starter ruleset parses cleanly and each rule matches its intended trigger`() {
        val assetFile = File("src/main/assets/yara_rules/android_starter.yar")
        assertTrue("expected to find ${assetFile.absolutePath}", assetFile.exists())
        val source = assetFile.readText()

        val errors = mutableListOf<String>()
        val rules = YaraParser.parseRulesLenient(source) { name, e -> errors.add("$name: ${e.message}") }
        assertTrue("bundled ruleset had parse errors: $errors", errors.isEmpty())
        assertEquals(4, rules.size)

        val eicarHit = YaraEngine.scan(rules, "xxx EICAR-STANDARD-ANTIVIRUS-TEST-FILE xxx".toByteArray())
        assertTrue(eicarHit.any { it.rule.name == "EICAR_Test_String_Present" })

        val fridaHit = YaraEngine.scan(rules, "...libfrida-gadget.so...".toByteArray())
        assertTrue(fridaHit.any { it.rule.name == "Bundles_Frida_Instrumentation_Toolkit" })

        val msfHit = YaraEngine.scan(rules, "Lcom/metasploit/stage/Payload;".toByteArray())
        assertTrue(msfHit.any { it.rule.name == "Metasploit_Android_Meterpreter_Stager" })

        val suHit = YaraEngine.scan(rules, "path=/system/bin/su end".toByteArray())
        assertTrue(suHit.any { it.rule.name == "Bundles_Root_Su_Binary_Paths" })
        // fullword: must NOT match as a substring of a longer path segment
        val suNoMatch = YaraEngine.scan(rules, "path=/system/bin/sudoers end".toByteArray())
        assertFalse(suNoMatch.any { it.rule.name == "Bundles_Root_Su_Binary_Paths" })

        val clean = YaraEngine.scan(rules, "nothing interesting in this apk".toByteArray())
        assertTrue(clean.isEmpty())
    }
}

Build configuration

Gradle build files and the Android manifest — permissions, components, dependencies.

settings.gradle.kts 17 lines
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "WazuhAndroidAgent"
include(":app")
build.gradle.kts 4 lines
plugins {
    id("com.android.application") version "8.5.2" apply false
    id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
app/build.gradle.kts 62 lines
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "org.wazuh.androidagent"
    compileSdk = 34

    defaultConfig {
        applicationId = "org.wazuh.androidagent"
        minSdk = 26
        targetSdk = 34
        versionCode = 1
        versionName = "0.1.0"
    }

    signingConfigs {
        create("release") {
            // Self-signed for internal/lab distribution. Replace with your own keystore for production.
            storeFile = file("../keystore/release.keystore")
            storePassword = "[redacted — throwaway lab keystore]"
            keyAlias = "wazuhagent"
            keyPassword = "[redacted — throwaway lab keystore]"
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
            signingConfig = signingConfigs.getByName("release")
        }
        debug {
            isMinifyEnabled = false
            signingConfig = signingConfigs.getByName("release")
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }

    buildFeatures {
        viewBinding = true
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("com.google.android.material:material:1.12.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
    implementation("androidx.activity:activity-ktx:1.9.0")
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
    implementation("androidx.recyclerview:recyclerview:1.3.2")
    testImplementation("junit:junit:4.13.2")
}
app/src/main/AndroidManifest.xml 84 lines
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- Core networking -->
    <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" />

    <!-- Keep the agent alive as a visible foreground service -->
    <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" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <!-- Needed to enumerate the full installed-app inventory (security telemetry) -->
    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />

    <!-- Optional, opt-in "full system log" collector (SystemLogCollector). This is a
         signature|privileged permission on modern Android: it cannot be requested via
         a runtime dialog, cannot be granted from any in-app Settings screen, and the
         Play Store would reject an app declaring it. It only works sideloaded, and only
         after `adb shell pm grant org.wazuh.androidagent android.permission.READ_LOGS`
         (see the app's website for the exact command). Declaring it here is required
         for that ADB grant and the runtime checkSelfPermission() check to work at all,
         even though the manifest declaration alone grants nothing by itself. -->
    <uses-permission android:name="android.permission.READ_LOGS" tools:ignore="ProtectedPermissions" />

    <application
        android:name=".AgentApplication"
        android:allowBackup="false"
        android:usesCleartextTraffic="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/Theme.WazuhAgent"
        android:networkSecurityConfig="@xml/network_security_config">

        <activity
            android:name=".ui.MainActivity"
            android:exported="true"
            android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity
            android:name=".ui.LiveLogActivity"
            android:exported="false"
            android:label="Live Log" />

        <activity
            android:name=".ui.AiAnalysisActivity"
            android:exported="false"
            android:label="Wazuh API + AI Analysis" />

        <service
            android:name=".AgentForegroundService"
            android:exported="false"
            android:foregroundServiceType="dataSync" />

        <service
            android:name=".collectors.NotificationAgentListenerService"
            android:exported="false"
            android:label="@string/app_name"
            android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
            <intent-filter>
                <action android:name="android.service.notification.NotificationListenerService" />
            </intent-filter>
        </service>

        <receiver
            android:name=".BootReceiver"
            android:exported="true"
            android:enabled="true">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
            </intent-filter>
        </receiver>

    </application>
</manifest>