diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index f6884c99d5..1ddfce32a1 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -68,6 +68,22 @@ analytics_notice
analytics_okay
analytics_platforms
any
+### APP ###
+app_functions_get_channel_info
+app_functions_get_device_status
+app_functions_get_mesh_metrics
+app_functions_get_mesh_status
+app_functions_get_node_details
+app_functions_get_node_list
+app_functions_get_recent_messages
+app_functions_get_unread_summary
+app_functions_master_summary
+app_functions_master_toggle
+app_functions_read_section
+app_functions_send_message
+app_functions_settings
+app_functions_settings_summary
+app_functions_write_section
app_notifications
app_settings
app_too_old
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 480989d8a1..6d135119b0 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -31,6 +31,7 @@ plugins {
alias(libs.plugins.secrets)
id("meshtastic.aboutlibraries")
id("dev.mokkery")
+ alias(libs.plugins.devtools.ksp)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -177,6 +178,8 @@ secrets {
propertiesFileName = "secrets.properties"
}
+ksp { arg("appfunctions:aggregateAppFunctions", "true") }
+
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.flavorName?.let { flavor -> variant.applicationId.set("com.geeksville.mesh.$flavor.debug") }
@@ -282,6 +285,10 @@ dependencies {
googleImplementation(libs.firebase.ai.ondevice)
googleImplementation(libs.mlkit.translate)
+ googleImplementation(libs.androidx.appfunctions)
+ googleImplementation(libs.androidx.appfunctions.service)
+ add("kspGoogle", libs.androidx.appfunctions.compiler)
+
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 6e797e9520..a659cc5b48 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -17,6 +17,12 @@
package org.meshtastic.app.di
import org.koin.core.annotation.Module
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
@Module(includes = [FDroidNetworkModule::class, FdroidAiModule::class])
-class FlavorModule
+class FlavorModule {
+ @Single
+ @Named("googleServicesAvailable")
+ fun googleServicesAvailable(): Boolean = false
+}
diff --git a/androidApp/src/google/AndroidManifest.xml b/androidApp/src/google/AndroidManifest.xml
index c4138cb0bd..234f47eba3 100644
--- a/androidApp/src/google/AndroidManifest.xml
+++ b/androidApp/src/google/AndroidManifest.xml
@@ -16,12 +16,18 @@
~ along with this program. If not, see .
-->
-
+
-
+
+
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt b/androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt
new file mode 100644
index 0000000000..9e9970235f
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app
+
+import androidx.appfunctions.service.AppFunctionConfiguration
+import org.koin.java.KoinJavaComponent.getKoin
+import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
+
+/**
+ * Google flavor Application subclass that configures App Functions.
+ *
+ * Registers a custom factory so the AppFunctions runtime can instantiate [MeshtasticAppFunctions] with its Koin-managed
+ * dependencies.
+ */
+class GoogleMeshUtilApplication :
+ MeshUtilApplication(),
+ AppFunctionConfiguration.Provider {
+
+ override val appFunctionConfiguration: AppFunctionConfiguration
+ get() =
+ AppFunctionConfiguration.Builder()
+ .addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
+ getKoin().get()
+ }
+ .build()
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
new file mode 100644
index 0000000000..e2892d4636
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import androidx.appfunctions.AppFunctionIntValueConstraint
+import androidx.appfunctions.AppFunctionSerializable
+import androidx.appfunctions.AppFunctionStringValueConstraint
+
+/** Response returned when a message is successfully sent via the mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class SendMessageResponse(
+ /** The identifier assigned to the outgoing message. */
+ val messageId: Int,
+ /** The channel or destination the message was sent to. */
+ val channel: String,
+ /** The time the message was sent (epoch milliseconds). */
+ val timestamp: Long,
+)
+
+/** Response containing the current status of the Meshtastic mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class MeshStatusResponse(
+ /** The current radio connection state (e.g., CONNECTED, DISCONNECTED). */
+ @property:AppFunctionStringValueConstraint(enumValues = ["CONNECTED", "DISCONNECTED", "DEVICE_SLEEP"])
+ val connectionState: String,
+ /** The number of nodes currently online (heard within the last 2 hours). */
+ val onlineNodeCount: Int,
+ /** The total number of nodes known to the network. */
+ val totalNodeCount: Int,
+ /** The battery percentage of the connected Meshtastic device (1-100), or null if unavailable. */
+ val localBatteryLevel: Int?,
+ /** The display name of the local node, or null if not set. */
+ val localNodeName: String?,
+)
+
+/** Information about a single mesh node. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class NodeInfo(
+ /** The unique node identifier in Meshtastic hex format (e.g., !abc12345). */
+ val id: String,
+ /** The human-readable name of the node. */
+ val name: String,
+ /** The node's battery percentage (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** The time this node was last heard from (epoch milliseconds). */
+ val lastHeard: Long,
+ /** Whether this node is currently considered online. */
+ val isOnline: Boolean,
+)
+
+/** Response containing a list of nodes visible on the mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetNodeListResponse(
+ /** List of nodes sorted by most recently heard first. */
+ val nodes: List,
+)
+
+/** Information about a single mesh channel. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class ChannelInfo(
+ /** The channel index (0-7). */
+ @property:AppFunctionIntValueConstraint(enumValues = [0, 1, 2, 3, 4, 5, 6, 7]) val index: Int,
+ /** The human-readable name of the channel. */
+ val name: String,
+ /** Whether this is the primary/default channel. */
+ val isPrimary: Boolean,
+ /** Whether uplink is enabled for this channel. */
+ val uplinkEnabled: Boolean,
+ /** Whether downlink is enabled for this channel. */
+ val downlinkEnabled: Boolean,
+)
+
+/** Response containing the list of available mesh channels. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetChannelInfoResponse(
+ /** List of all configured channels. */
+ val channels: List,
+)
+
+/** Response containing the status of the local Meshtastic device. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetDeviceStatusResponse(
+ /** The hardware model of the device (e.g., "Meshtastic nRF52840"). */
+ val model: String,
+ /** The firmware version string. */
+ val firmwareVersion: String,
+ /** The device battery percentage (0-100), or null if not battery-powered. */
+ val batteryLevel: Int?,
+ /** The charging state (CHARGING, NOT_CHARGING, or UNKNOWN). */
+ @property:AppFunctionStringValueConstraint(enumValues = ["CHARGING", "NOT_CHARGING", "UNKNOWN"])
+ val chargingStatus: String,
+ /** The display name of the device, or null if not set. */
+ val deviceName: String?,
+ /** Whether the radio is currently active and connected. */
+ val isActive: Boolean,
+)
+
+/** Response containing detailed telemetry for a specific mesh node. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetNodeDetailsResponse(
+ /** Node ID in hex format (e.g., "!abc12345"). */
+ val id: String,
+ /** User ID string for this node. */
+ val userId: String,
+ /** Display name of the node. */
+ val name: String,
+ /** Battery percentage (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** Supply voltage in volts, or null if unavailable. */
+ val voltage: Float?,
+ /** Hardware model string. */
+ val hardwareModel: String,
+ /** Firmware version string. */
+ val firmwareVersion: String,
+ /** Signal-to-noise ratio of strongest signal. */
+ val snr: Float,
+ /** Received signal strength indicator in dB. */
+ val rssi: Int,
+ /** Number of hops away from local node (-1 if unknown). */
+ val hopsAway: Int,
+ /** Channel index this node is on. */
+ val channel: Int,
+ /** Last heard timestamp (milliseconds since epoch). */
+ val lastHeard: Long,
+ /** User role or device type. */
+ val userRole: String,
+ /** Whether the user is licensed. */
+ val isLicensed: Boolean,
+ /** Latitude in degrees, or null if unknown. */
+ val latitude: Double?,
+ /** Longitude in degrees, or null if unknown. */
+ val longitude: Double?,
+)
+
+/** Response containing aggregate mesh network metrics. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetMeshMetricsResponse(
+ /** Total number of known nodes. */
+ val totalNodeCount: Int,
+ /** Number of nodes currently online. */
+ val onlineNodeCount: Int,
+ /** Average battery level across mesh, or null if no data. */
+ val averageBatteryLevel: Int?,
+ /** Estimated health score (0-100). */
+ val meshHealthScore: Int,
+ /** Timestamp of most recent packet (ms since epoch). */
+ val mostRecentPacketTime: Long,
+ /** Mesh uptime in seconds. */
+ val meshUptimeSeconds: Long,
+ /** Channel utilization percentage, or null if unavailable. */
+ val channelUtilizationPercent: Int?,
+)
+
+/** Response containing recent messages from the mesh network. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetRecentMessagesResponse(
+ /** List of recent messages ordered by most recent first. */
+ val messages: List,
+)
+
+/** Information about a single mesh message. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class MessageInfo(
+ /** Display name of the message sender. */
+ val senderName: String,
+ /** The message text content. */
+ val text: String,
+ /** Name of the channel or contact the message belongs to. */
+ val contactName: String,
+ /** Timestamp when the message was received (ms since epoch). */
+ val receivedTime: Long,
+ /** True if this message was sent by the local user. */
+ val fromLocal: Boolean,
+ /** True if this message has been read by the user. */
+ val read: Boolean,
+)
+
+/** Response containing a summary of unread messages across all contacts. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class GetUnreadSummaryResponse(
+ /** Total number of unread messages across all non-muted contacts. */
+ val totalUnreadCount: Int,
+ /** Per-contact breakdown of unread messages, sorted by most recent. */
+ val contacts: List,
+)
+
+/** Unread message details for a single contact or channel. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+data class ContactUnreadInfo(
+ /** Display name of the contact or channel. */
+ val name: String,
+ /** Number of unread messages from this contact. */
+ val unreadCount: Int,
+ /** Preview text of the most recent message (up to 100 chars), or null if unavailable. */
+ val lastMessagePreview: String?,
+ /** Timestamp of the most recent message (ms since epoch), or null if unavailable. */
+ val lastMessageTime: Long?,
+)
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionStateSync.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionStateSync.kt
new file mode 100644
index 0000000000..f252b30d64
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionStateSync.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import android.content.Context
+import androidx.appfunctions.AppFunctionManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+/**
+ * Observes [AppFunctionsPrefs] and synchronizes the enabled/disabled state of each AppFunction with the system via
+ * [AppFunctionManager].
+ *
+ * When the master toggle is off, all functions are disabled regardless of individual toggles.
+ */
+class AppFunctionStateSync(
+ private val context: Context,
+ private val prefs: AppFunctionsPrefs,
+ dispatchers: CoroutineDispatchers,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ init {
+ observeAndSync()
+ }
+
+ private fun observeAndSync() {
+ data class FunctionToggle(val id: String, val enabled: StateFlow)
+
+ val functions =
+ listOf(
+ FunctionToggle(SEND_MESSAGE_ID, prefs.sendMessageEnabled),
+ FunctionToggle(GET_MESH_STATUS_ID, prefs.getMeshStatusEnabled),
+ FunctionToggle(GET_NODE_LIST_ID, prefs.getNodeListEnabled),
+ FunctionToggle(GET_CHANNEL_INFO_ID, prefs.getChannelInfoEnabled),
+ FunctionToggle(GET_DEVICE_STATUS_ID, prefs.getDeviceStatusEnabled),
+ FunctionToggle(GET_NODE_DETAILS_ID, prefs.getNodeDetailsEnabled),
+ FunctionToggle(GET_MESH_METRICS_ID, prefs.getMeshMetricsEnabled),
+ FunctionToggle(GET_RECENT_MESSAGES_ID, prefs.getRecentMessagesEnabled),
+ FunctionToggle(GET_UNREAD_SUMMARY_ID, prefs.getUnreadSummaryEnabled),
+ )
+
+ // Combine master toggle with each individual toggle
+ combine(prefs.masterEnabled, combine(functions.map { it.enabled }) { it.toList() }) { master, toggles ->
+ functions.mapIndexed { index, fn -> fn.id to (master && toggles[index]) }
+ }
+ .onEach { states -> syncStates(states) }
+ .launchIn(scope)
+ }
+
+ private suspend fun syncStates(states: List>) {
+ val manager = AppFunctionManager.getInstance(context) ?: return
+
+ for ((functionId, enabled) in states) {
+ val state =
+ if (enabled) {
+ AppFunctionManager.APP_FUNCTION_STATE_ENABLED
+ } else {
+ AppFunctionManager.APP_FUNCTION_STATE_DISABLED
+ }
+ try {
+ manager.setAppFunctionEnabled(functionId, state)
+ } catch (_: Exception) {
+ // Function may not be indexed yet (first launch)
+ }
+ }
+ }
+
+ companion object {
+ private const val CLASS_PREFIX = "org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions#"
+
+ const val SEND_MESSAGE_ID = "${CLASS_PREFIX}sendMessage"
+ const val GET_MESH_STATUS_ID = "${CLASS_PREFIX}getMeshStatus"
+ const val GET_NODE_LIST_ID = "${CLASS_PREFIX}getNodeList"
+ const val GET_CHANNEL_INFO_ID = "${CLASS_PREFIX}getChannelInfo"
+ const val GET_DEVICE_STATUS_ID = "${CLASS_PREFIX}getDeviceStatus"
+ const val GET_NODE_DETAILS_ID = "${CLASS_PREFIX}getNodeDetails"
+ const val GET_MESH_METRICS_ID = "${CLASS_PREFIX}getMeshMetrics"
+ const val GET_RECENT_MESSAGES_ID = "${CLASS_PREFIX}getRecentMessages"
+ const val GET_UNREAD_SUMMARY_ID = "${CLASS_PREFIX}getUnreadSummary"
+ }
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt
new file mode 100644
index 0000000000..0d0c65aecd
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionElementNotFoundException
+import androidx.appfunctions.AppFunctionIntValueConstraint
+import androidx.appfunctions.AppFunctionInvalidArgumentException
+import androidx.appfunctions.AppFunctionNotSupportedException
+import androidx.appfunctions.service.AppFunction
+import kotlinx.coroutines.TimeoutCancellationException
+import org.meshtastic.core.data.ai.AiFunctionProvider
+import org.meshtastic.core.data.ai.SendMessageResult
+
+/**
+ * Exposes Meshtastic mesh networking capabilities to system AI assistants via the Android App Functions API. Functions
+ * declared here are discoverable by the system and can be invoked by AI agents such as Gemini.
+ */
+class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
+
+ /**
+ * Send a text message over the Meshtastic mesh radio network.
+ *
+ * Messages are transmitted to nearby mesh nodes using LoRa radio. The mesh network is ideal for off-grid
+ * communications where cellular service is unavailable.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @param text The message text to send (max 237 bytes).
+ * @param recipientName Optional name of a specific node to send a direct message to. If omitted, the message is
+ * broadcast to all nodes on the specified channel.
+ * @param channelName Optional channel name to broadcast on. If omitted, uses the primary channel. Ignored when
+ * recipientName is specified.
+ * @return A [SendMessageResponse] with the message ID, channel, and timestamp.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun sendMessage(
+ context: AppFunctionContext,
+ text: String,
+ recipientName: String? = null,
+ channelName: String? = null,
+ ): SendMessageResponse {
+ val result =
+ try {
+ provider.sendMessage(text, recipientName, channelName)
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return when (result) {
+ is SendMessageResult.Success ->
+ SendMessageResponse(
+ messageId = result.messageId,
+ channel = result.channel,
+ timestamp = result.timestamp,
+ )
+
+ is SendMessageResult.NotConnected -> throw AppFunctionNotSupportedException(result.message)
+
+ is SendMessageResult.AmbiguousName -> {
+ val names = result.candidates.joinToString()
+ throw AppFunctionInvalidArgumentException(
+ "Multiple nodes match that name: $names. Please be more specific.",
+ )
+ }
+
+ is SendMessageResult.InvalidArgument -> throw AppFunctionInvalidArgumentException(result.reason)
+
+ is SendMessageResult.RateLimited ->
+ throw AppFunctionInvalidArgumentException(
+ "Rate limit exceeded. Try again in ${result.retryAfterSeconds} seconds.",
+ )
+ }
+ }
+
+ /**
+ * Get the current status of the Meshtastic mesh network.
+ *
+ * Returns connection state, number of online nodes, total known nodes, the connected device's battery level, and
+ * the local node name.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A [MeshStatusResponse] with the current mesh network status.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMeshStatus(context: AppFunctionContext): MeshStatusResponse {
+ val status =
+ try {
+ provider.getMeshStatus()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return MeshStatusResponse(
+ connectionState = status.connectionState,
+ onlineNodeCount = status.onlineNodeCount,
+ totalNodeCount = status.totalNodeCount,
+ localBatteryLevel = status.localBatteryLevel,
+ localNodeName = status.localNodeName,
+ )
+ }
+
+ /**
+ * List all nodes currently visible on the Meshtastic mesh network.
+ *
+ * Returns detailed information about each node including name, battery level, and last heard time. Nodes are sorted
+ * by most recently heard first.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A list of nodes with their current status and metrics.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getNodeList(context: AppFunctionContext): GetNodeListResponse {
+ val result =
+ try {
+ provider.getNodeList()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetNodeListResult.Success ->
+ GetNodeListResponse(
+ nodes =
+ result.nodes.map {
+ NodeInfo(
+ id = it.id,
+ name = it.name,
+ batteryLevel = it.batteryLevel,
+ lastHeard = it.lastHeard,
+ isOnline = it.isOnline,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetNodeListResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetNodeListResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * List all available Meshtastic mesh channels and their configurations.
+ *
+ * Returns details about each channel including name, index, primary status, and uplink/downlink settings.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A list of channels with their current configuration.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getChannelInfo(context: AppFunctionContext): GetChannelInfoResponse {
+ val result =
+ try {
+ provider.getChannelInfo()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetChannelInfoResult.Success ->
+ GetChannelInfoResponse(
+ channels =
+ result.channels.map {
+ ChannelInfo(
+ index = it.index,
+ name = it.name,
+ isPrimary = it.isPrimary,
+ uplinkEnabled = it.uplinkEnabled,
+ downlinkEnabled = it.downlinkEnabled,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetChannelInfoResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetChannelInfoResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Get the status and metrics of the local Meshtastic radio device.
+ *
+ * Returns hardware model, firmware version, battery level, charging status, and current radio state.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return Device status with current metrics and configuration.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getDeviceStatus(context: AppFunctionContext): GetDeviceStatusResponse {
+ val result =
+ try {
+ provider.getDeviceStatus()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the device is initialized and try again.",
+ )
+ }
+
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetDeviceStatusResult.Success ->
+ GetDeviceStatusResponse(
+ model = result.device.model,
+ firmwareVersion = result.device.firmwareVersion,
+ batteryLevel = result.device.batteryLevel,
+ chargingStatus = result.device.chargingStatus,
+ deviceName = result.device.deviceName,
+ isActive = result.device.isActive,
+ )
+
+ is org.meshtastic.core.data.ai.GetDeviceStatusResult.NotAvailable ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetDeviceStatusResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Retrieve detailed telemetry and status for a specific mesh node.
+ *
+ * Returns per-node metrics including battery level, signal strength, hardware model, and location data.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @param nodeId The target node ID (e.g., '!abc12345' or user ID).
+ * @return A [GetNodeDetailsResponse] with detailed node information.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getNodeDetails(context: AppFunctionContext, nodeId: String): GetNodeDetailsResponse {
+ val result =
+ try {
+ provider.getNodeDetails(nodeId)
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.Success ->
+ GetNodeDetailsResponse(
+ id = result.node.id,
+ userId = result.node.userId,
+ name = result.node.name,
+ batteryLevel = result.node.batteryLevel,
+ voltage = result.node.voltage,
+ hardwareModel = result.node.hardwareModel,
+ firmwareVersion = result.node.firmwareVersion,
+ snr = result.node.snr,
+ rssi = result.node.rssi,
+ hopsAway = result.node.hopsAway,
+ channel = result.node.channel,
+ lastHeard = result.node.lastHeard,
+ userRole = result.node.userRole,
+ isLicensed = result.node.isLicensed,
+ latitude = result.node.latitude,
+ longitude = result.node.longitude,
+ )
+
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.NotFound ->
+ throw AppFunctionElementNotFoundException(result.message)
+
+ is org.meshtastic.core.data.ai.GetNodeDetailsResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Retrieve aggregate network metrics and statistics for the entire mesh.
+ *
+ * Returns mesh-wide analytics including total node count, online nodes, average battery level, and health score.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A [GetMeshMetricsResponse] with mesh-wide statistics.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMeshMetrics(context: AppFunctionContext): GetMeshMetricsResponse {
+ val result =
+ try {
+ provider.getMeshMetrics()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException(
+ "Request timed out. Ensure the mesh is connected and try again.",
+ )
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetMeshMetricsResult.Success ->
+ GetMeshMetricsResponse(
+ totalNodeCount = result.metrics.totalNodeCount,
+ onlineNodeCount = result.metrics.onlineNodeCount,
+ averageBatteryLevel = result.metrics.averageBatteryLevel,
+ meshHealthScore = result.metrics.meshHealthScore,
+ mostRecentPacketTime = result.metrics.mostRecentPacketTime,
+ meshUptimeSeconds = result.metrics.meshUptimeSeconds,
+ channelUtilizationPercent = result.metrics.channelUtilizationPercent,
+ )
+
+ is org.meshtastic.core.data.ai.GetMeshMetricsResult.NotConnected ->
+ throw AppFunctionNotSupportedException(result.message)
+
+ is org.meshtastic.core.data.ai.GetMeshMetricsResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Retrieve recent messages received over the Meshtastic mesh radio network.
+ *
+ * Returns a list of recent messages from the local message history. Messages are stored locally and do not require
+ * an active mesh connection. Useful for catching up on conversations or reviewing recent communications.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from
+ * all contacts sorted by most recent.
+ * @param limit Maximum number of messages to return (1–50). Defaults to 20.
+ * @return A [GetRecentMessagesResponse] containing the list of recent messages.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getRecentMessages(
+ context: AppFunctionContext,
+ contactName: String? = null,
+ @AppFunctionIntValueConstraint(enumValues = [1, 5, 10, 20, 50])
+ limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT,
+ ): GetRecentMessagesResponse {
+ val result =
+ try {
+ provider.getRecentMessages(contactName, limit)
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException("Request timed out. Try again or reduce the message limit.")
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetRecentMessagesResult.Success ->
+ GetRecentMessagesResponse(
+ messages =
+ result.messages.map { msg ->
+ MessageInfo(
+ senderName = msg.senderName,
+ text = msg.text,
+ contactName = msg.contactName,
+ receivedTime = msg.receivedTime,
+ fromLocal = msg.fromLocal,
+ read = msg.read,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetRecentMessagesResult.ContactNotFound ->
+ throw AppFunctionElementNotFoundException(result.message)
+
+ is org.meshtastic.core.data.ai.GetRecentMessagesResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+
+ /**
+ * Get a summary of unread messages across all Meshtastic mesh contacts.
+ *
+ * Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread,
+ * and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection.
+ *
+ * @param context The app function invocation context provided by the system.
+ * @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getUnreadSummary(context: AppFunctionContext): GetUnreadSummaryResponse {
+ val result =
+ try {
+ provider.getUnreadSummary()
+ } catch (_: TimeoutCancellationException) {
+ throw AppFunctionInvalidArgumentException("Request timed out. Try again.")
+ }
+ return when (result) {
+ is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Success ->
+ GetUnreadSummaryResponse(
+ totalUnreadCount = result.summary.totalUnreadCount,
+ contacts =
+ result.summary.contacts.map { contact ->
+ ContactUnreadInfo(
+ name = contact.name,
+ unreadCount = contact.unreadCount,
+ lastMessagePreview = contact.lastMessagePreview,
+ lastMessageTime = contact.lastMessageTime,
+ )
+ },
+ )
+
+ is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Error ->
+ throw AppFunctionInvalidArgumentException(result.reason)
+ }
+ }
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt
new file mode 100644
index 0000000000..c632f14882
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.di
+
+import android.content.Context
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.app.ai.appfunctions.AppFunctionStateSync
+import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
+import org.meshtastic.core.data.ai.AiFunctionProvider
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+/** Provides AppFunctions integration for the Google flavor. */
+@Module
+class AppFunctionsModule {
+ @Single
+ fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
+
+ @Single(createdAtStart = true)
+ fun appFunctionStateSync(
+ context: Context,
+ prefs: AppFunctionsPrefs,
+ dispatchers: CoroutineDispatchers,
+ ): AppFunctionStateSync = AppFunctionStateSync(context, prefs, dispatchers)
+
+ @Single
+ @Named("googleServicesAvailable")
+ fun googleServicesAvailable(): Boolean = true
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 20fe0bff6d..b0ecb874cc 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -19,5 +19,8 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
-@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class])
+@Module(
+ includes =
+ [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class],
+)
class FlavorModule
diff --git a/androidApp/src/google/res/xml/app_metadata.xml b/androidApp/src/google/res/xml/app_metadata.xml
new file mode 100644
index 0000000000..1ef23a2b2a
--- /dev/null
+++ b/androidApp/src/google/res/xml/app_metadata.xml
@@ -0,0 +1,26 @@
+
+
+
+
diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
index ba56e1790f..ef6e3c9f77 100644
--- a/androidApp/src/main/res/values/strings.xml
+++ b/androidApp/src/main/res/values/strings.xml
@@ -17,4 +17,5 @@
-->
Meshtastic
+ Off-grid mesh networking for secure, long-range communications via LoRa radio.
diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctionsTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctionsTest.kt
new file mode 100644
index 0000000000..edc7bb3479
--- /dev/null
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctionsTest.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.ai.appfunctions
+
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionElementNotFoundException
+import androidx.appfunctions.AppFunctionInvalidArgumentException
+import androidx.appfunctions.AppFunctionNotSupportedException
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.ai.AiFunctionProvider
+import org.meshtastic.core.data.ai.ChannelSummary
+import org.meshtastic.core.data.ai.ContactUnread
+import org.meshtastic.core.data.ai.DeviceStatus
+import org.meshtastic.core.data.ai.GetChannelInfoResult
+import org.meshtastic.core.data.ai.GetDeviceStatusResult
+import org.meshtastic.core.data.ai.GetMeshMetricsResult
+import org.meshtastic.core.data.ai.GetNodeDetailsResult
+import org.meshtastic.core.data.ai.GetNodeListResult
+import org.meshtastic.core.data.ai.GetRecentMessagesResult
+import org.meshtastic.core.data.ai.GetUnreadSummaryResult
+import org.meshtastic.core.data.ai.MeshMetrics
+import org.meshtastic.core.data.ai.MeshStatusResult
+import org.meshtastic.core.data.ai.MessageSummary
+import org.meshtastic.core.data.ai.NodeDetails
+import org.meshtastic.core.data.ai.NodeSummary
+import org.meshtastic.core.data.ai.SendMessageResult
+import org.meshtastic.core.data.ai.UnreadSummary
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35], application = android.app.Application::class)
+class MeshtasticAppFunctionsTest {
+
+ private val provider: AiFunctionProvider = mock(MockMode.autofill)
+ private val context: AppFunctionContext = mock(MockMode.autofill)
+ private val appFunctions = MeshtasticAppFunctions(provider)
+
+ @Test
+ fun sendMessage_success() = runTest {
+ everySuspend { provider.sendMessage("Hello", "Alice", null) } returns
+ SendMessageResult.Success(messageId = 1234, channel = "Primary", timestamp = 1700000000L)
+
+ val response = appFunctions.sendMessage(context, "Hello", "Alice", null)
+
+ assertEquals(1234, response.messageId)
+ assertEquals("Primary", response.channel)
+ assertEquals(1700000000L, response.timestamp)
+ }
+
+ @Test
+ fun sendMessage_ambiguousName() = runTest {
+ everySuspend { provider.sendMessage("Hello", "Al", null) } returns
+ SendMessageResult.AmbiguousName(listOf("Alice", "Albert"))
+
+ val exception =
+ assertFailsWith {
+ appFunctions.sendMessage(context, "Hello", "Al", null)
+ }
+ assertTrue(exception.message!!.contains("Multiple nodes match that name"))
+ }
+
+ @Test
+ fun sendMessage_notConnected() = runTest {
+ everySuspend { provider.sendMessage("Hello", "Alice", null) } returns
+ SendMessageResult.NotConnected("Not connected")
+
+ assertFailsWith { appFunctions.sendMessage(context, "Hello", "Alice", null) }
+ }
+
+ @Test
+ fun getMeshStatus_success() = runTest {
+ everySuspend { provider.getMeshStatus() } returns
+ MeshStatusResult(
+ connectionState = "CONNECTED",
+ onlineNodeCount = 5,
+ totalNodeCount = 10,
+ localBatteryLevel = 88,
+ localNodeName = "MyNode",
+ )
+
+ val response = appFunctions.getMeshStatus(context)
+
+ assertEquals("CONNECTED", response.connectionState)
+ assertEquals(5, response.onlineNodeCount)
+ assertEquals(10, response.totalNodeCount)
+ assertEquals(88, response.localBatteryLevel)
+ assertEquals("MyNode", response.localNodeName)
+ }
+
+ @Test
+ fun getNodeList_success() = runTest {
+ val nodes =
+ listOf(
+ NodeSummary(id = "1", name = "Alice", batteryLevel = 90, lastHeard = 1700000000L, isOnline = true),
+ NodeSummary(id = "2", name = "Bob", batteryLevel = null, lastHeard = 1600000000L, isOnline = false),
+ )
+ everySuspend { provider.getNodeList() } returns GetNodeListResult.Success(nodes)
+
+ val response = appFunctions.getNodeList(context)
+
+ assertEquals(2, response.nodes.size)
+ assertEquals("1", response.nodes[0].id)
+ assertEquals("Alice", response.nodes[0].name)
+ assertEquals(90, response.nodes[0].batteryLevel)
+ assertTrue(response.nodes[0].isOnline)
+ assertEquals("Bob", response.nodes[1].name)
+ assertEquals(null, response.nodes[1].batteryLevel)
+ }
+
+ @Test
+ fun getChannelInfo_success() = runTest {
+ val channels =
+ listOf(
+ ChannelSummary(
+ index = 0,
+ name = "Primary",
+ isPrimary = true,
+ uplinkEnabled = true,
+ downlinkEnabled = true,
+ ),
+ ChannelSummary(
+ index = 1,
+ name = "Secondary",
+ isPrimary = false,
+ uplinkEnabled = true,
+ downlinkEnabled = false,
+ ),
+ )
+ everySuspend { provider.getChannelInfo() } returns GetChannelInfoResult.Success(channels)
+
+ val response = appFunctions.getChannelInfo(context)
+
+ assertEquals(2, response.channels.size)
+ assertEquals("Primary", response.channels[0].name)
+ assertTrue(response.channels[0].isPrimary)
+ assertTrue(response.channels[0].uplinkEnabled)
+ assertEquals("Secondary", response.channels[1].name)
+ }
+
+ @Test
+ fun getDeviceStatus_success() = runTest {
+ val device =
+ DeviceStatus(
+ model = "T-Beam",
+ firmwareVersion = "2.3.15",
+ batteryLevel = 100,
+ chargingStatus = "NOT_CHARGING",
+ deviceName = "MyDevice",
+ isActive = true,
+ )
+ everySuspend { provider.getDeviceStatus() } returns GetDeviceStatusResult.Success(device)
+
+ val response = appFunctions.getDeviceStatus(context)
+
+ assertEquals("T-Beam", response.model)
+ assertEquals("2.3.15", response.firmwareVersion)
+ assertEquals(100, response.batteryLevel)
+ assertEquals("NOT_CHARGING", response.chargingStatus)
+ assertEquals("MyDevice", response.deviceName)
+ assertTrue(response.isActive)
+ }
+
+ @Test
+ fun getNodeDetails_success() = runTest {
+ val nodeDetails =
+ NodeDetails(
+ id = "!abc12345",
+ userId = "abc12345",
+ name = "TestNode",
+ batteryLevel = 75,
+ voltage = 3.9f,
+ hardwareModel = "T-Echo",
+ firmwareVersion = "2.3.15",
+ snr = 5.5f,
+ rssi = -90,
+ hopsAway = 1,
+ channel = 0,
+ lastHeard = 1700000000L,
+ userRole = "CLIENT",
+ isLicensed = false,
+ latitude = 45.0,
+ longitude = -90.0,
+ )
+ everySuspend { provider.getNodeDetails("!abc12345") } returns GetNodeDetailsResult.Success(nodeDetails)
+
+ val response = appFunctions.getNodeDetails(context, "!abc12345")
+
+ assertEquals("!abc12345", response.id)
+ assertEquals("TestNode", response.name)
+ assertEquals(75, response.batteryLevel)
+ assertEquals(3.9f, response.voltage)
+ assertEquals(45.0, response.latitude)
+ }
+
+ @Test
+ fun getNodeDetails_notFound() = runTest {
+ everySuspend { provider.getNodeDetails("!unknown") } returns GetNodeDetailsResult.NotFound("Node not found")
+
+ assertFailsWith { appFunctions.getNodeDetails(context, "!unknown") }
+ }
+
+ @Test
+ fun getMeshMetrics_success() = runTest {
+ val metrics =
+ MeshMetrics(
+ totalNodeCount = 12,
+ onlineNodeCount = 4,
+ averageBatteryLevel = 82,
+ meshHealthScore = 95,
+ mostRecentPacketTime = 1700000000L,
+ meshUptimeSeconds = 3600L,
+ channelUtilizationPercent = 5,
+ )
+ everySuspend { provider.getMeshMetrics() } returns GetMeshMetricsResult.Success(metrics)
+
+ val response = appFunctions.getMeshMetrics(context)
+
+ assertEquals(12, response.totalNodeCount)
+ assertEquals(4, response.onlineNodeCount)
+ assertEquals(82, response.averageBatteryLevel)
+ assertEquals(95, response.meshHealthScore)
+ }
+
+ @Test
+ fun getRecentMessages_success() = runTest {
+ val messages =
+ listOf(
+ MessageSummary(
+ senderName = "Alice",
+ text = "Hi",
+ contactName = "Alice",
+ receivedTime = 1700000000L,
+ fromLocal = false,
+ read = true,
+ ),
+ )
+ everySuspend { provider.getRecentMessages(null, 10) } returns GetRecentMessagesResult.Success(messages)
+
+ val response = appFunctions.getRecentMessages(context, null, 10)
+
+ assertEquals(1, response.messages.size)
+ assertEquals("Alice", response.messages[0].senderName)
+ assertEquals("Hi", response.messages[0].text)
+ }
+
+ @Test
+ fun getUnreadSummary_success() = runTest {
+ val summary =
+ UnreadSummary(
+ totalUnreadCount = 3,
+ contacts =
+ listOf(
+ ContactUnread(
+ name = "Alice",
+ unreadCount = 2,
+ lastMessagePreview = "Hi",
+ lastMessageTime = 1700000000L,
+ ),
+ ),
+ )
+ everySuspend { provider.getUnreadSummary() } returns GetUnreadSummaryResult.Success(summary)
+
+ val response = appFunctions.getUnreadSummary(context)
+
+ assertEquals(3, response.totalUnreadCount)
+ assertEquals(1, response.contacts.size)
+ assertEquals("Alice", response.contacts[0].name)
+ assertEquals(2, response.contacts[0].unreadCount)
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt
new file mode 100644
index 0000000000..4829e53e18
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+/**
+ * Platform-agnostic contract defining operations that AI systems can invoke.
+ *
+ * This interface abstracts the app capabilities exposed to system AI assistants. On Android, the implementation is
+ * wired to AppFunctions. On other platforms, equivalent mechanisms (App Intents on iOS, MCP on Desktop) can implement
+ * this.
+ */
+interface AiFunctionProvider {
+
+ /**
+ * Send a text message over the mesh network.
+ *
+ * The destination is resolved by name using fuzzy matching — either a node name for direct messages or a channel
+ * name for broadcast. If both are null, the message is broadcast on the primary channel.
+ *
+ * @param text The message text to send.
+ * @param recipientName Optional node name for direct messages.
+ * @param channelName Optional channel name. Defaults to primary channel if omitted.
+ * @return Result indicating success or a typed failure reason.
+ */
+ suspend fun sendMessage(text: String, recipientName: String? = null, channelName: String? = null): SendMessageResult
+
+ /**
+ * Get the current mesh network status summary.
+ *
+ * @return Current connection state, node counts, and local device info.
+ */
+ suspend fun getMeshStatus(): MeshStatusResult
+
+ /**
+ * List all nodes currently visible on the mesh network.
+ *
+ * @return Success with list of nodes, or failure if not connected.
+ */
+ suspend fun getNodeList(): GetNodeListResult
+
+ /**
+ * List all available mesh channels and their configurations.
+ *
+ * @return Success with list of channels, or failure if not connected.
+ */
+ suspend fun getChannelInfo(): GetChannelInfoResult
+
+ /**
+ * Get status and metrics of the local mesh radio device.
+ *
+ * @return Success with device status, or failure if device unavailable.
+ */
+ suspend fun getDeviceStatus(): GetDeviceStatusResult
+
+ /**
+ * Get detailed telemetry and status for a specific mesh node.
+ *
+ * @param nodeId The target node ID (in Meshtastic format: "!hex" or user ID).
+ * @return Success with node details, or failure if not connected or node not found.
+ */
+ suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResult
+
+ /**
+ * Get aggregate network metrics and statistics for the entire mesh.
+ *
+ * @return Success with mesh metrics, or failure if not connected.
+ */
+ suspend fun getMeshMetrics(): GetMeshMetricsResult
+
+ /**
+ * Get recent messages from the mesh network.
+ *
+ * Messages are returned from the local cache — an active radio connection is not required.
+ *
+ * @param contactName Optional contact/channel name to filter by. Uses fuzzy matching.
+ * @param limit Maximum number of messages to return (default 20, max 50).
+ * @return Success with list of messages, or failure if contact not found.
+ */
+ suspend fun getRecentMessages(
+ contactName: String? = null,
+ limit: Int = DEFAULT_MESSAGE_LIMIT,
+ ): GetRecentMessagesResult
+
+ /**
+ * Get a summary of unread messages grouped by contact.
+ *
+ * Returns the total unread count and a per-contact breakdown with the last message preview. Muted contacts are
+ * excluded.
+ *
+ * @return Unread summary with per-contact breakdown.
+ */
+ suspend fun getUnreadSummary(): GetUnreadSummaryResult
+
+ companion object {
+ const val DEFAULT_MESSAGE_LIMIT = 20
+ const val MAX_MESSAGE_LIMIT = 50
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
new file mode 100644
index 0000000000..d5ffed7fc8
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
@@ -0,0 +1,527 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeout
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Implementation of [AiFunctionProvider] that bridges AI function invocations to existing Meshtastic repositories and
+ * use cases.
+ */
+@Suppress("TooManyFunctions")
+@Single(binds = [AiFunctionProvider::class])
+class AiFunctionProviderImpl(
+ private val serviceRepository: ServiceRepository,
+ private val nodeRepository: NodeRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val packetRepository: PacketRepository,
+ private val sendMessageUseCase: SendMessageUseCase,
+ private val fuzzyNameResolver: FuzzyNameResolver,
+ private val rateLimiter: RateLimiter,
+ private val clock: Clock,
+) : AiFunctionProvider {
+
+ override suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult =
+ withTimeout(OPERATION_TIMEOUT) {
+ // Check connection
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout SendMessageResult.NotConnected(
+ "Not connected to a Meshtastic radio. Please connect first.",
+ )
+ }
+
+ // Check rate limit
+ when (val rateResult = rateLimiter.tryAcquire()) {
+ is RateLimitResult.Permitted -> {
+ /* proceed */
+ }
+
+ is RateLimitResult.Limited -> {
+ return@withTimeout SendMessageResult.RateLimited(rateResult.retryAfterSeconds)
+ }
+ }
+
+ // Validate message length
+ val messageBytes = text.encodeToByteArray()
+ if (messageBytes.size > MAX_MESSAGE_LENGTH) {
+ return@withTimeout SendMessageResult.InvalidArgument(
+ "Message too long: ${messageBytes.size} bytes exceeds maximum of $MAX_MESSAGE_LENGTH bytes.",
+ )
+ }
+
+ // Resolve destination
+ val contactKey =
+ resolveContactKey(recipientName, channelName)
+ ?: return@withTimeout SendMessageResult.InvalidArgument("Could not resolve destination.")
+
+ // Handle ambiguous results from resolution
+ if (contactKey is ResolvedContact.Ambiguous) {
+ return@withTimeout SendMessageResult.AmbiguousName(contactKey.candidates)
+ }
+
+ val key = (contactKey as ResolvedContact.Resolved).contactKey
+
+ // Send via existing use case and capture the generated messageId
+ try {
+ val messageId = sendMessageUseCase.invoke(text, key)
+
+ SendMessageResult.Success(
+ messageId = messageId,
+ channel = contactKey.channelName,
+ timestamp = clock.now().toEpochMilliseconds(),
+ )
+ } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
+ if (ex is CancellationException) throw ex
+ SendMessageResult.InvalidArgument("Failed to send message: ${ex.message}")
+ }
+ }
+
+ override suspend fun getMeshStatus(): MeshStatusResult = withTimeout(OPERATION_TIMEOUT) {
+ val connectionState = serviceRepository.connectionState.value
+ val onlineCount = nodeRepository.onlineNodeCount.first()
+ val totalCount = nodeRepository.totalNodeCount.first()
+ val ourNode = nodeRepository.ourNodeInfo.value
+ val batteryLevel = ourNode?.batteryLevel?.takeIf { it in 1..MAX_BATTERY_LEVEL }
+ val nodeName = ourNode?.user?.long_name?.takeIf { it.isNotBlank() }
+
+ MeshStatusResult(
+ connectionState = connectionState.name,
+ onlineNodeCount = onlineCount,
+ totalNodeCount = totalCount,
+ localBatteryLevel = batteryLevel,
+ localNodeName = nodeName,
+ )
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getNodeList(): GetNodeListResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetNodeListResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val nodeMap = nodeRepository.nodeDBbyNum.first()
+ val nodes =
+ nodeMap.values.map { node ->
+ NodeSummary(
+ id = "!${node.num.toString(HEX_RADIX)}",
+ name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
+ batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
+ lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
+ isOnline = node.isOnline,
+ )
+ }
+ GetNodeListResult.Success(nodes.sortedByDescending { it.lastHeard })
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetNodeListResult.Error("Failed to retrieve node list: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getChannelInfo(): GetChannelInfoResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetChannelInfoResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val channels =
+ channelSet.settings.mapIndexed { index, channel ->
+ ChannelSummary(
+ index = index,
+ name = channel.name.takeIf { it.isNotBlank() } ?: "Channel $index",
+ isPrimary = index == 0,
+ uplinkEnabled = channel.uplink_enabled,
+ downlinkEnabled = channel.downlink_enabled,
+ )
+ }
+ GetChannelInfoResult.Success(channels)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetChannelInfoResult.Error("Failed to retrieve channel info: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getDeviceStatus(): GetDeviceStatusResult = withTimeout(OPERATION_TIMEOUT) {
+ try {
+ val ourNode =
+ nodeRepository.ourNodeInfo.value
+ ?: return@withTimeout GetDeviceStatusResult.NotAvailable("Device not yet initialized.")
+
+ val deviceStatus =
+ DeviceStatus(
+ model = ourNode.metadata?.hw_model?.name ?: "Unknown",
+ firmwareVersion = ourNode.metadata?.firmware_version ?: "Unknown",
+ batteryLevel = ourNode.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
+ chargingStatus = "UNKNOWN",
+ deviceName = ourNode.user.long_name.takeIf { it.isNotBlank() },
+ isActive = serviceRepository.connectionState.value == ConnectionState.Connected,
+ )
+ GetDeviceStatusResult.Success(deviceStatus)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetDeviceStatusResult.Error("Failed to retrieve device status: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetNodeDetailsResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val node =
+ if (nodeId.startsWith("!")) {
+ // Hex format: extract number and search
+ val nodeNum = nodeId.drop(1).toInt(HEX_RADIX)
+ nodeRepository.nodeDBbyNum.first()[nodeNum]
+ } else {
+ // User ID format
+ nodeRepository.getNode(nodeId)
+ }
+
+ if (node == null) {
+ return@withTimeout GetNodeDetailsResult.NotFound("Node not found: $nodeId")
+ }
+
+ // Check if position is valid (both coords zero AND time zero indicates no position fix)
+ val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0 || node.position.time > 0
+
+ val details =
+ NodeDetails(
+ id = "!${node.num.toString(HEX_RADIX)}",
+ userId = node.user.id,
+ name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
+ batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
+ voltage = node.deviceMetrics.voltage,
+ hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
+ firmwareVersion = node.metadata?.firmware_version ?: "Unknown",
+ snr = node.snr,
+ rssi = node.rssi,
+ hopsAway = node.hopsAway,
+ channel = node.channel,
+ lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
+ userRole = node.user.role.name,
+ isLicensed = node.user.is_licensed,
+ latitude = node.latitude.takeIf { hasValidPosition },
+ longitude = node.longitude.takeIf { hasValidPosition },
+ )
+ GetNodeDetailsResult.Success(details)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetNodeDetailsResult.Error("Failed to retrieve node details: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getMeshMetrics(): GetMeshMetricsResult = withTimeout(OPERATION_TIMEOUT) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ return@withTimeout GetMeshMetricsResult.NotConnected("Not connected to a Meshtastic radio.")
+ }
+
+ try {
+ val totalCount = nodeRepository.totalNodeCount.first()
+ val onlineCount = nodeRepository.onlineNodeCount.first()
+
+ // Calculate average battery level
+ val nodeMap = nodeRepository.nodeDBbyNum.first()
+ val batteryLevels = nodeMap.values.mapNotNull { it.deviceMetrics.battery_level }
+ val avgBattery =
+ if (batteryLevels.isNotEmpty()) {
+ (batteryLevels.sum() / batteryLevels.size).coerceIn(0, MAX_BATTERY_LEVEL)
+ } else {
+ null
+ }
+
+ // Mesh health score: 0-100 based on online ratio and recent activity
+ val healthScore =
+ when {
+ totalCount == 0 -> 0
+ onlineCount == 0 -> HEALTH_SCORE_DEGRADED
+ else -> (HEALTH_SCORE_BASE + (HEALTH_SCORE_ONLINE_RATIO * onlineCount) / totalCount).toInt()
+ }
+
+ // Find most recent packet: max lastHeard across all nodes (convert seconds to ms)
+ val mostRecentPacketTimeMs =
+ nodeMap.values.maxOfOrNull { it.lastHeard }?.takeIf { it > 0 }?.toLong()?.times(MS_PER_SEC)
+ ?: clock.now().toEpochMilliseconds()
+
+ // Get local device uptime from its DeviceMetrics (node #0 is typically the local device)
+ val localNode = nodeMap.values.find { it.num == 0 } ?: nodeMap.values.firstOrNull()
+ val meshUptimeSeconds = localNode?.deviceMetrics?.uptime_seconds?.toLong() ?: 0L
+
+ val metrics =
+ MeshMetrics(
+ totalNodeCount = totalCount,
+ onlineNodeCount = onlineCount,
+ averageBatteryLevel = avgBattery,
+ meshHealthScore = healthScore.coerceIn(0, HEALTH_SCORE_MAX),
+ mostRecentPacketTime = mostRecentPacketTimeMs,
+ meshUptimeSeconds = meshUptimeSeconds,
+ channelUtilizationPercent = null, // Could compute from radioConfigRepository if needed
+ )
+ GetMeshMetricsResult.Success(metrics)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetMeshMetricsResult.Error("Failed to retrieve mesh metrics: ${ex.message}")
+ }
+ }
+
+ @Suppress("ReturnCount", "TooGenericExceptionCaught")
+ override suspend fun getRecentMessages(contactName: String?, limit: Int): GetRecentMessagesResult =
+ withTimeout(OPERATION_TIMEOUT) {
+ try {
+ val effectiveLimit = limit.coerceIn(1, AiFunctionProvider.MAX_MESSAGE_LIMIT)
+
+ // Resolve contact key if a name filter is provided
+ val contactKey =
+ if (contactName != null) {
+ resolveContactKeyForRead(contactName)
+ ?: return@withTimeout GetRecentMessagesResult.ContactNotFound(
+ "Contact not found: $contactName",
+ )
+ } else {
+ null
+ }
+
+ val messages =
+ if (contactKey != null) {
+ // Fetch messages from a specific contact
+ packetRepository
+ .getMessagesFrom(
+ contact = contactKey,
+ limit = effectiveLimit,
+ includeFiltered = false,
+ getNode = { userId -> nodeRepository.getNode(userId ?: "") },
+ )
+ .first()
+ } else {
+ // Fetch recent messages across all contacts
+ val contacts = packetRepository.getContacts().first()
+ contacts.keys
+ .flatMap { key ->
+ packetRepository
+ .getMessagesFrom(
+ contact = key,
+ limit = MESSAGES_PER_CONTACT,
+ includeFiltered = false,
+ getNode = { userId -> nodeRepository.getNode(userId ?: "") },
+ )
+ .first()
+ }
+ .sortedByDescending { it.receivedTime }
+ .take(effectiveLimit)
+ }
+
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val summaries =
+ messages.map { msg ->
+ MessageSummary(
+ senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}",
+ text = msg.text,
+ contactName = resolveContactDisplayName(msg, channelSet),
+ receivedTime = msg.receivedTime,
+ fromLocal = msg.fromLocal,
+ read = msg.read,
+ )
+ }
+
+ GetRecentMessagesResult.Success(summaries)
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetRecentMessagesResult.Error("Failed to retrieve messages: ${ex.message}")
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun getUnreadSummary(): GetUnreadSummaryResult = withTimeout(OPERATION_TIMEOUT) {
+ try {
+ val contacts = packetRepository.getContacts().first()
+ val settings = packetRepository.getContactSettings().first()
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val nodeMap = nodeRepository.nodeDBbyNum.first()
+
+ val nonMutedContacts = contacts.filter { (key, _) -> settings[key]?.isMuted != true }
+
+ val contactUnreads =
+ nonMutedContacts.mapNotNull { (contactKey, lastPacket) ->
+ val unreadCount = packetRepository.getUnreadCount(contactKey)
+ if (unreadCount <= 0) return@mapNotNull null
+
+ val isBroadcast = lastPacket.to == DataPacket.ID_BROADCAST
+ val displayName =
+ if (isBroadcast) {
+ val channelIndex = contactKey.firstOrNull()?.digitToIntOrNull() ?: 0
+ channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
+ ?: "Channel $channelIndex"
+ } else {
+ val userId = lastPacket.from ?: ""
+ val node = nodeMap.values.find { it.user.id == userId }
+ node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown"
+ }
+
+ ContactUnread(
+ name = displayName,
+ unreadCount = unreadCount,
+ lastMessagePreview = lastPacket.text?.take(MESSAGE_PREVIEW_MAX_LENGTH),
+ lastMessageTime = lastPacket.time.takeIf { it > 0 },
+ )
+ }
+
+ val totalUnread = contactUnreads.sumOf { it.unreadCount }
+
+ GetUnreadSummaryResult.Success(
+ UnreadSummary(
+ totalUnreadCount = totalUnread,
+ contacts = contactUnreads.sortedByDescending { it.lastMessageTime },
+ ),
+ )
+ } catch (ex: Exception) {
+ if (ex is CancellationException) throw ex
+ GetUnreadSummaryResult.Error("Failed to retrieve unread summary: ${ex.message}")
+ }
+ }
+
+ /**
+ * Resolve a contact name (node or channel) to a contact key for reading messages. Returns null if the name cannot
+ * be resolved.
+ */
+ @Suppress("ReturnCount")
+ private suspend fun resolveContactKeyForRead(name: String): String? {
+ // Try node name first
+ when (val nodeResult = fuzzyNameResolver.resolveNodeName(name)) {
+ is NodeNameResult.Found -> {
+ val channelIndex = DataPacket.PKC_CHANNEL_INDEX
+ return "${channelIndex}${nodeResult.userId}"
+ }
+
+ is NodeNameResult.Ambiguous -> return null
+
+ is NodeNameResult.NotFound -> {
+ /* fall through to channel */
+ }
+ }
+
+ // Try channel name
+ return when (val channelResult = fuzzyNameResolver.resolveChannelName(name)) {
+ is ChannelNameResult.Found -> "${channelResult.channelIndex}${DataPacket.ID_BROADCAST}"
+ is ChannelNameResult.Ambiguous -> null
+ is ChannelNameResult.NotFound -> null
+ }
+ }
+
+ private fun resolveContactDisplayName(
+ msg: org.meshtastic.core.model.Message,
+ channelSet: org.meshtastic.proto.ChannelSet,
+ ): String {
+ // For broadcast messages, use channel name
+ val channelIndex = msg.node.channel
+ return channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
+ ?: "Channel $channelIndex"
+ }
+
+ @Suppress("ReturnCount")
+ private suspend fun resolveContactKey(recipientName: String?, channelName: String?): ResolvedContact? {
+ // Direct message to a specific node
+ if (recipientName != null) {
+ return when (val result = fuzzyNameResolver.resolveNodeName(recipientName)) {
+ is NodeNameResult.Found -> {
+ // DM contact key format: channel_index + nodeId
+ // For PKC DMs, use channel index 8; for legacy use no channel prefix
+ val channelIndex = DataPacket.PKC_CHANNEL_INDEX
+ ResolvedContact.Resolved(
+ contactKey = "${channelIndex}${result.userId}",
+ channelName = "DM to $recipientName",
+ )
+ }
+
+ is NodeNameResult.Ambiguous -> ResolvedContact.Ambiguous(result.candidates)
+
+ is NodeNameResult.NotFound -> {
+ return null
+ }
+ }
+ }
+
+ // Broadcast to a specific channel
+ if (channelName != null) {
+ return when (val result = fuzzyNameResolver.resolveChannelName(channelName)) {
+ is ChannelNameResult.Found ->
+ ResolvedContact.Resolved(
+ contactKey = "${result.channelIndex}${DataPacket.ID_BROADCAST}",
+ channelName = result.name,
+ )
+
+ is ChannelNameResult.Ambiguous -> ResolvedContact.Ambiguous(result.candidates)
+
+ is ChannelNameResult.NotFound -> null
+ }
+ }
+
+ // Default: broadcast on primary channel (index 0)
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val primaryName = channelSet.settings.firstOrNull()?.name?.ifBlank { "Primary" } ?: "Primary"
+ return ResolvedContact.Resolved(contactKey = "0${DataPacket.ID_BROADCAST}", channelName = primaryName)
+ }
+
+ private sealed class ResolvedContact {
+ data class Resolved(val contactKey: String, val channelName: String) : ResolvedContact()
+
+ data class Ambiguous(val candidates: List) : ResolvedContact()
+ }
+
+ companion object {
+ private val OPERATION_TIMEOUT = 5.seconds
+ private const val MAX_BATTERY_LEVEL = 100
+ private const val HEX_RADIX = 16
+ private const val MS_PER_SEC = 1000L
+ private const val HEALTH_SCORE_BASE = 50
+ private const val HEALTH_SCORE_ONLINE_RATIO = 50
+ private const val HEALTH_SCORE_DEGRADED = 10
+ private const val HEALTH_SCORE_MAX = 100
+ private const val MESSAGES_PER_CONTACT = 5
+ private const val MESSAGE_PREVIEW_MAX_LENGTH = 100
+
+ /** Standard Meshtastic message payload limit (bytes). */
+ const val MAX_MESSAGE_LENGTH = 237
+ }
+}
+
+/** Extension to get a display name for ConnectionState. */
+private val ConnectionState.name: String
+ get() =
+ when (this) {
+ ConnectionState.Connected -> "CONNECTED"
+ ConnectionState.Connecting -> "CONNECTING"
+ ConnectionState.Disconnected -> "DISCONNECTED"
+ ConnectionState.DeviceSleep -> "DEVICE_SLEEP"
+ }
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
new file mode 100644
index 0000000000..87619c73e3
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+/** Result of a [AiFunctionProvider.sendMessage] invocation. */
+sealed class SendMessageResult {
+ /** Message was successfully queued for transmission. */
+ data class Success(val messageId: Int, val channel: String, val timestamp: Long) : SendMessageResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : SendMessageResult()
+
+ /** The provided name matched multiple candidates. */
+ data class AmbiguousName(val candidates: List) : SendMessageResult()
+
+ /** An argument was invalid (e.g., message too long, name not found). */
+ data class InvalidArgument(val reason: String) : SendMessageResult()
+
+ /** Rate limit exceeded — too many AI-triggered sends in the time window. */
+ data class RateLimited(val retryAfterSeconds: Int) : SendMessageResult()
+}
+
+/** Result of a [AiFunctionProvider.getMeshStatus] invocation. */
+data class MeshStatusResult(
+ /** Current connection state (e.g., "CONNECTED", "DISCONNECTED"). */
+ val connectionState: String,
+ /** Number of nodes heard within the online threshold. */
+ val onlineNodeCount: Int,
+ /** Total number of nodes in the local database. */
+ val totalNodeCount: Int,
+ /** Local device battery level (0-100), or null if unavailable. */
+ val localBatteryLevel: Int?,
+ /** Display name of the local node, or null if not yet configured. */
+ val localNodeName: String?,
+)
+
+/** Result of a [AiFunctionProvider.getNodeList] invocation. */
+sealed class GetNodeListResult {
+ /** Successfully retrieved the list of visible mesh nodes. */
+ data class Success(val nodes: List) : GetNodeListResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetNodeListResult()
+
+ /** An error occurred retrieving the node list. */
+ data class Error(val reason: String) : GetNodeListResult()
+}
+
+/** Summary information for a single mesh node. */
+data class NodeSummary(
+ /** Node ID in Meshtastic hex format (e.g., "!abc12345"). */
+ val id: String,
+ /** Display name of the node. */
+ val name: String,
+ /** Battery level (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** Last time this node was heard from (milliseconds since epoch). */
+ val lastHeard: Long,
+ /** Whether this node is currently considered online. */
+ val isOnline: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getChannelInfo] invocation. */
+sealed class GetChannelInfoResult {
+ /** Successfully retrieved the list of channels. */
+ data class Success(val channels: List) : GetChannelInfoResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetChannelInfoResult()
+
+ /** An error occurred retrieving channel info. */
+ data class Error(val reason: String) : GetChannelInfoResult()
+}
+
+/** Summary information for a single mesh channel. */
+data class ChannelSummary(
+ /** Channel index (0-7). */
+ val index: Int,
+ /** Display name of the channel. */
+ val name: String,
+ /** Whether this is the primary/default channel. */
+ val isPrimary: Boolean,
+ /** Uplink enabled for this channel. */
+ val uplinkEnabled: Boolean,
+ /** Downlink enabled for this channel. */
+ val downlinkEnabled: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getDeviceStatus] invocation. */
+sealed class GetDeviceStatusResult {
+ /** Successfully retrieved device status. */
+ data class Success(val device: DeviceStatus) : GetDeviceStatusResult()
+
+ /** Device is not available or not connected. */
+ data class NotAvailable(val message: String) : GetDeviceStatusResult()
+
+ /** An error occurred retrieving device status. */
+ data class Error(val reason: String) : GetDeviceStatusResult()
+}
+
+/** Status and metrics of the local mesh radio device. */
+data class DeviceStatus(
+ /** Device model/hardware (e.g., "Meshtastic nRF52840"). */
+ val model: String,
+ /** Firmware version string. */
+ val firmwareVersion: String,
+ /** Battery level (0-100), or null if not battery-powered. */
+ val batteryLevel: Int?,
+ /** Charging status: "CHARGING", "NOT_CHARGING", or "UNKNOWN". */
+ val chargingStatus: String,
+ /** Display name of the device. */
+ val deviceName: String?,
+ /** Whether the radio is currently transmitting or receiving. */
+ val isActive: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getNodeDetails] invocation. */
+sealed class GetNodeDetailsResult {
+ /** Successfully retrieved node details. */
+ data class Success(val node: NodeDetails) : GetNodeDetailsResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetNodeDetailsResult()
+
+ /** Node with given ID not found. */
+ data class NotFound(val message: String) : GetNodeDetailsResult()
+
+ /** An error occurred retrieving node details. */
+ data class Error(val reason: String) : GetNodeDetailsResult()
+}
+
+/** Detailed telemetry and status for a specific node. */
+data class NodeDetails(
+ /** Node ID in Meshtastic hex format (e.g., "!abc12345"). */
+ val id: String,
+ /** User ID string for this node. */
+ val userId: String,
+ /** Display name of the node. */
+ val name: String,
+ /** Battery level (0-100), or null if unavailable. */
+ val batteryLevel: Int?,
+ /** Supply voltage in volts, or null if unavailable. */
+ val voltage: Float?,
+ /** Hardware model (e.g., "Meshtastic nRF52840"). */
+ val hardwareModel: String,
+ /** Firmware version string. */
+ val firmwareVersion: String,
+ /** Signal-to-noise ratio of the strongest received signal. */
+ val snr: Float,
+ /** Received signal strength indicator in dB. */
+ val rssi: Int,
+ /** Number of hops away from the local node (-1 if unknown). */
+ val hopsAway: Int,
+ /** Channel index this node is on. */
+ val channel: Int,
+ /** Last time this node was heard from (milliseconds since epoch). */
+ val lastHeard: Long,
+ /** User role or device type (e.g., "CLIENT", "REPEATER"). */
+ val userRole: String,
+ /** Whether user is licensed to operate this hardware. */
+ val isLicensed: Boolean,
+ /** Latitude (degrees), or null if not available. */
+ val latitude: Double?,
+ /** Longitude (degrees), or null if not available. */
+ val longitude: Double?,
+)
+
+/** Result of a [AiFunctionProvider.getMeshMetrics] invocation. */
+sealed class GetMeshMetricsResult {
+ /** Successfully retrieved mesh metrics. */
+ data class Success(val metrics: MeshMetrics) : GetMeshMetricsResult()
+
+ /** Device is not connected to a Meshtastic radio. */
+ data class NotConnected(val message: String) : GetMeshMetricsResult()
+
+ /** An error occurred retrieving mesh metrics. */
+ data class Error(val reason: String) : GetMeshMetricsResult()
+}
+
+/** Aggregate network metrics and statistics for the entire mesh. */
+data class MeshMetrics(
+ /** Total number of nodes known to this device. */
+ val totalNodeCount: Int,
+ /** Number of nodes that are currently online. */
+ val onlineNodeCount: Int,
+ /** Average battery level across all nodes, or null if unknown. */
+ val averageBatteryLevel: Int?,
+ /** Estimated mesh health score (0-100), based on connectivity and node activity. */
+ val meshHealthScore: Int,
+ /** Timestamp of the most recent packet received (milliseconds since epoch). */
+ val mostRecentPacketTime: Long,
+ /** Mesh uptime since local node startup (seconds). */
+ val meshUptimeSeconds: Long,
+ /** Estimated channel utilization percentage (0-100), or null if unavailable. */
+ val channelUtilizationPercent: Int?,
+)
+
+/** Result of a [AiFunctionProvider.getRecentMessages] invocation. */
+sealed class GetRecentMessagesResult {
+ /** Successfully retrieved recent messages. */
+ data class Success(val messages: List) : GetRecentMessagesResult()
+
+ /** The specified contact was not found via fuzzy matching. */
+ data class ContactNotFound(val message: String) : GetRecentMessagesResult()
+
+ /** An error occurred retrieving messages. */
+ data class Error(val reason: String) : GetRecentMessagesResult()
+}
+
+/** Summary of a single mesh message suitable for AI consumption. */
+data class MessageSummary(
+ /** Display name of the message sender. */
+ val senderName: String,
+ /** The message text content. */
+ val text: String,
+ /** Channel or contact name this message belongs to. */
+ val contactName: String,
+ /** When the message was received (milliseconds since epoch). */
+ val receivedTime: Long,
+ /** Whether this message was sent by the local user. */
+ val fromLocal: Boolean,
+ /** Whether this message has been read. */
+ val read: Boolean,
+)
+
+/** Result of a [AiFunctionProvider.getUnreadSummary] invocation. */
+sealed class GetUnreadSummaryResult {
+ /** Successfully retrieved unread summary. */
+ data class Success(val summary: UnreadSummary) : GetUnreadSummaryResult()
+
+ /** An error occurred retrieving unread summary. */
+ data class Error(val reason: String) : GetUnreadSummaryResult()
+}
+
+/** Unread message summary across all contacts. */
+data class UnreadSummary(
+ /** Total number of unread messages across all contacts. */
+ val totalUnreadCount: Int,
+ /** Per-contact breakdown of unread messages (excludes muted contacts). */
+ val contacts: List,
+)
+
+/** Unread info for a single contact or channel. */
+data class ContactUnread(
+ /** Display name of the contact or channel. */
+ val name: String,
+ /** Number of unread messages from this contact. */
+ val unreadCount: Int,
+ /** Preview of the last message text, or null if none. */
+ val lastMessagePreview: String?,
+ /** Timestamp of the last message (milliseconds since epoch), or null if none. */
+ val lastMessageTime: Long?,
+)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt
new file mode 100644
index 0000000000..d809284f62
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.flow.first
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+
+/**
+ * Resolves fuzzy node and channel name queries to concrete identifiers.
+ *
+ * Uses longest-common-substring matching with a minimum threshold of 50% of the query length. Returns an error with
+ * candidate list if ambiguous.
+ */
+@Single
+class FuzzyNameResolver(
+ private val nodeRepository: NodeRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+) {
+
+ /** Resolve a node name query to a node number and user ID. */
+ fun resolveNodeName(query: String): NodeNameResult {
+ val nodes = nodeRepository.nodeDBbyNum.value
+ val candidates =
+ nodes.values
+ .filter { it.user.long_name.isNotBlank() }
+ .map { NameCandidate(it.user.long_name, it.num, it.user.id) }
+
+ return matchName(query, candidates)
+ }
+
+ /**
+ * Resolve a channel name query to a channel index.
+ *
+ * Admin channels are excluded from resolution (NFR-001).
+ */
+ @Suppress("ReturnCount")
+ suspend fun resolveChannelName(query: String): ChannelNameResult {
+ val channelSet = radioConfigRepository.channelSetFlow.first()
+ val candidates =
+ channelSet.settings
+ .mapIndexed { index, settings -> IndexedChannel(settings.name, index) }
+ .filter { it.name.isNotBlank() }
+ // Exclude admin channels (convention: channel named "admin" is sensitive)
+ .filter { !it.name.equals("admin", ignoreCase = true) }
+
+ if (candidates.isEmpty()) return ChannelNameResult.NotFound
+
+ // Exact match first
+ candidates
+ .firstOrNull { it.name.equals(query, ignoreCase = true) }
+ ?.let {
+ return ChannelNameResult.Found(it.index, it.name)
+ }
+
+ // Fuzzy match
+ val scored =
+ candidates
+ .map { it to longestCommonSubstringLength(query.lowercase(), it.name.lowercase()) }
+ .filter { (_, score) -> score >= (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1) }
+ .sortedByDescending { it.second }
+
+ return when {
+ scored.isEmpty() -> ChannelNameResult.NotFound
+ scored.size == 1 -> ChannelNameResult.Found(scored[0].first.index, scored[0].first.name)
+ scored[0].second > scored[1].second -> ChannelNameResult.Found(scored[0].first.index, scored[0].first.name)
+ else -> ChannelNameResult.Ambiguous(scored.map { it.first.name })
+ }
+ }
+
+ @Suppress("ReturnCount")
+ private fun matchName(query: String, candidates: List): NodeNameResult {
+ if (candidates.isEmpty()) return NodeNameResult.NotFound
+
+ // Exact match first (case-insensitive)
+ candidates
+ .firstOrNull { it.name.equals(query, ignoreCase = true) }
+ ?.let {
+ return NodeNameResult.Found(it.nodeNum, it.userId)
+ }
+
+ // Fuzzy match using longest common substring
+ val minScore = (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1)
+ val scored =
+ candidates
+ .map { it to longestCommonSubstringLength(query.lowercase(), it.name.lowercase()) }
+ .filter { (_, score) -> score >= minScore }
+ .sortedByDescending { it.second }
+
+ return when {
+ scored.isEmpty() -> NodeNameResult.NotFound
+
+ scored.size == 1 -> NodeNameResult.Found(scored[0].first.nodeNum, scored[0].first.userId)
+
+ scored[0].second > scored[1].second -> {
+ // Clear winner — top score is strictly greater
+ NodeNameResult.Found(scored[0].first.nodeNum, scored[0].first.userId)
+ }
+
+ else -> NodeNameResult.Ambiguous(scored.map { it.first.name })
+ }
+ }
+
+ private data class NameCandidate(val name: String, val nodeNum: Int, val userId: String)
+
+ private data class IndexedChannel(val name: String, val index: Int)
+
+ companion object {
+ /** Minimum match ratio — longest common substring must be ≥50% of query length. */
+ const val MATCH_THRESHOLD = 0.5
+ }
+}
+
+/** Compute the length of the longest common substring between two strings. */
+internal fun longestCommonSubstringLength(a: String, b: String): Int {
+ if (a.isEmpty() || b.isEmpty()) return 0
+ var maxLen = 0
+ // Space-optimized: only need previous row
+ val prev = IntArray(b.length + 1)
+ val curr = IntArray(b.length + 1)
+ for (i in 1..a.length) {
+ for (j in 1..b.length) {
+ curr[j] =
+ if (a[i - 1] == b[j - 1]) {
+ (prev[j - 1] + 1).also { if (it > maxLen) maxLen = it }
+ } else {
+ 0
+ }
+ }
+ prev.indices.forEach {
+ prev[it] = curr[it]
+ curr[it] = 0
+ }
+ }
+ return maxLen
+}
+
+sealed class NodeNameResult {
+ data class Found(val nodeNum: Int, val userId: String) : NodeNameResult()
+
+ data class Ambiguous(val candidates: List) : NodeNameResult()
+
+ data object NotFound : NodeNameResult()
+}
+
+sealed class ChannelNameResult {
+ data class Found(val channelIndex: Int, val name: String) : ChannelNameResult()
+
+ data class Ambiguous(val candidates: List) : ChannelNameResult()
+
+ data object NotFound : ChannelNameResult()
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt
new file mode 100644
index 0000000000..58dc189177
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Single
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+/**
+ * Sliding-window rate limiter for AI-triggered operations.
+ *
+ * Tracks the last [maxCalls] invocation timestamps. A new call is permitted only if fewer than [maxCalls] occurred
+ * within the [windowDuration]. This prevents aggregate AI traffic from flooding the mesh network.
+ *
+ * The limiter is intentionally process-scoped and global so concurrent AI surfaces share a single airtime budget.
+ */
+@Single
+class RateLimiter(private val clock: Clock) {
+
+ private val mutex = Mutex()
+ private val timestamps = ArrayDeque(MAX_CALLS)
+
+ /**
+ * Attempt to acquire a permit for one invocation.
+ *
+ * @return [RateLimitResult.Permitted] if under the limit, or [RateLimitResult.Limited] with the number of seconds
+ * until a slot frees up.
+ */
+ suspend fun tryAcquire(): RateLimitResult = mutex.withLock {
+ val now = clock.now()
+ val windowStart = now - WINDOW_DURATION
+
+ // Evict timestamps outside the window
+ while (timestamps.isNotEmpty() && timestamps.first() <= windowStart) {
+ timestamps.removeFirst()
+ }
+
+ return if (timestamps.size < MAX_CALLS) {
+ timestamps.addLast(now)
+ RateLimitResult.Permitted
+ } else {
+ val oldestInWindow = timestamps.first()
+ val retryAfter = ((oldestInWindow + WINDOW_DURATION) - now).inWholeSeconds.toInt() + 1
+ RateLimitResult.Limited(retryAfterSeconds = retryAfter.coerceAtLeast(1))
+ }
+ }
+
+ companion object {
+ const val MAX_CALLS = 5
+ val WINDOW_DURATION = 60.seconds
+ }
+}
+
+sealed class RateLimitResult {
+ data object Permitted : RateLimitResult()
+
+ data class Limited(val retryAfterSeconds: Int) : RateLimitResult()
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt
new file mode 100644
index 0000000000..5b87e65133
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt
@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import org.meshtastic.proto.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+class AiFunctionProviderImplTest {
+
+ private val connectionState = MutableStateFlow(ConnectionState.Connected)
+ private val serviceRepository: ServiceRepository =
+ mock(MockMode.autofill) { every { connectionState } returns this@AiFunctionProviderImplTest.connectionState }
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill)
+ private val fuzzyNameResolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
+ private val clock = TestClock(Instant.fromEpochSeconds(1_700_000_000))
+ private val rateLimiter = RateLimiter(clock)
+
+ private fun createProvider() = AiFunctionProviderImpl(
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ radioConfigRepository = radioConfigRepository,
+ sendMessageUseCase = sendMessageUseCase,
+ fuzzyNameResolver = fuzzyNameResolver,
+ packetRepository = packetRepository,
+ rateLimiter = rateLimiter,
+ clock = clock,
+ )
+
+ // --- getNodeDetails tests ---
+
+ @Test
+ fun getNodeDetails_returns_not_connected_when_disconnected() = runTest {
+ connectionState.value = ConnectionState.Disconnected
+ val provider = createProvider()
+
+ val result = provider.getNodeDetails("!abc123")
+ assertIs(result)
+ }
+
+ @Test
+ fun getNodeDetails_returns_not_found_for_unknown_node() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!ffffff")
+
+ assertIs(result)
+ }
+
+ @Test
+ fun getNodeDetails_returns_node_data_for_valid_hex_id() = runTest {
+ val testNode =
+ Node(
+ num = 0xabc,
+ user = User(id = "!00000abc", long_name = "Alice", short_name = "AL"),
+ lastHeard = 1_700_000_000,
+ snr = 5.5f,
+ rssi = -70,
+ channel = 0,
+ hopsAway = 1,
+ )
+ val nodeMap = MutableStateFlow(mapOf(0xabc to testNode))
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!abc")
+
+ assertIs(result)
+ assertEquals("Alice", result.node.name)
+ assertEquals(5.5f, result.node.snr)
+ assertEquals(-70, result.node.rssi)
+ assertEquals(1, result.node.hopsAway)
+ }
+
+ @Test
+ fun getNodeDetails_returns_null_position_when_no_fix() = runTest {
+ // Node with (0.0, 0.0) position and time=0 → no valid position
+ val testNode = Node(num = 1, user = User(id = "!00000001", long_name = "NoGPS", short_name = "NG"))
+ val nodeMap = MutableStateFlow(mapOf(1 to testNode))
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!1")
+
+ assertIs(result)
+ assertNull(result.node.latitude)
+ assertNull(result.node.longitude)
+ }
+
+ @Test
+ fun getNodeDetails_returns_error_for_invalid_hex_format() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+
+ val provider = createProvider()
+ val result = provider.getNodeDetails("!not_hex")
+
+ // Invalid hex should result in NotFound or Error
+ val isHandled = result is GetNodeDetailsResult.NotFound || result is GetNodeDetailsResult.Error
+ assertEquals(true, isHandled)
+ }
+
+ // --- getMeshMetrics tests ---
+
+ @Test
+ fun getMeshMetrics_returns_not_connected_when_disconnected() = runTest {
+ connectionState.value = ConnectionState.Disconnected
+ val provider = createProvider()
+
+ val result = provider.getMeshMetrics()
+ assertIs(result)
+ }
+
+ @Test
+ fun getMeshMetrics_returns_valid_metrics_with_active_nodes() = runTest {
+ val nodes = mapOf(1 to Node(num = 1, lastHeard = 1_699_999_990), 2 to Node(num = 2, lastHeard = 1_699_999_980))
+ val nodeMap = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(2)
+ every { nodeRepository.onlineNodeCount } returns flowOf(2)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs(result)
+ assertEquals(2, result.metrics.totalNodeCount)
+ assertEquals(2, result.metrics.onlineNodeCount)
+ // Health score: 50 + (50 * 2) / 2 = 100
+ assertEquals(100, result.metrics.meshHealthScore)
+ // Most recent packet: 1_699_999_990 * 1000
+ assertEquals(1_699_999_990_000L, result.metrics.mostRecentPacketTime)
+ }
+
+ @Test
+ fun getMeshMetrics_returns_zero_health_score_when_empty() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(0)
+ every { nodeRepository.onlineNodeCount } returns flowOf(0)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs(result)
+ assertEquals(0, result.metrics.totalNodeCount)
+ assertEquals(0, result.metrics.meshHealthScore)
+ }
+
+ @Test
+ fun getMeshMetrics_falls_back_to_current_time_when_all_lastHeard_zero() = runTest {
+ val nodes = mapOf(1 to Node(num = 1, lastHeard = 0))
+ val nodeMap = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(1)
+ every { nodeRepository.onlineNodeCount } returns flowOf(0)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs(result)
+ // Falls back to clock.now() since all lastHeard are 0
+ assertEquals(clock.now().toEpochMilliseconds(), result.metrics.mostRecentPacketTime)
+ }
+
+ @Test
+ fun getMeshMetrics_returns_degraded_health_when_no_nodes_online() = runTest {
+ val nodes = mapOf(1 to Node(num = 1, lastHeard = 1_000))
+ val nodeMap = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { nodeRepository.totalNodeCount } returns flowOf(1)
+ every { nodeRepository.onlineNodeCount } returns flowOf(0)
+
+ val provider = createProvider()
+ val result = provider.getMeshMetrics()
+
+ assertIs(result)
+ // HEALTH_SCORE_DEGRADED = 10
+ assertEquals(10, result.metrics.meshHealthScore)
+ }
+
+ // --- sendMessage error propagation test ---
+
+ @Test
+ fun sendMessage_returns_not_connected_when_disconnected() = runTest {
+ connectionState.value = ConnectionState.Disconnected
+ val provider = createProvider()
+
+ val result = provider.sendMessage("hello", null, null)
+ assertIs(result)
+ }
+
+ @Test
+ fun sendMessage_returns_rate_limited_when_exhausted() = runTest {
+ val provider = createProvider()
+
+ // Exhaust rate limit
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ val result = provider.sendMessage("hello", null, null)
+ assertIs(result)
+ }
+
+ // --- getRecentMessages tests ---
+
+ @Test
+ fun getRecentMessages_contact_not_found() = runTest {
+ val nodeMap = MutableStateFlow(emptyMap())
+ every { nodeRepository.nodeDBbyNum } returns nodeMap
+ every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
+
+ val provider = createProvider()
+ val result = provider.getRecentMessages("NonExistent", 10)
+ assertIs(result)
+ }
+
+ // --- getUnreadSummary tests ---
+
+ @Test
+ fun getUnreadSummary_returns_empty_when_no_unread() = runTest {
+ every { packetRepository.getContacts() } returns flowOf(emptyMap())
+ every { packetRepository.getContactSettings() } returns flowOf(emptyMap())
+ every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+
+ val provider = createProvider()
+ val result = provider.getUnreadSummary()
+ assertIs(result)
+ assertEquals(0, result.summary.totalUnreadCount)
+ assertEquals(0, result.summary.contacts.size)
+ }
+}
+
+private class TestClock(var currentTime: Instant) : Clock {
+ override fun now(): Instant = currentTime
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt
new file mode 100644
index 0000000000..3e75bfe749
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+class FuzzyNameResolverTest {
+
+ @Test
+ fun longestCommonSubstring_exact_match() {
+ assertEquals(5, longestCommonSubstringLength("hello", "hello"))
+ }
+
+ @Test
+ fun longestCommonSubstring_partial_match() {
+ assertEquals(3, longestCommonSubstringLength("abcdef", "xbcdx"))
+ }
+
+ @Test
+ fun longestCommonSubstring_no_match() {
+ assertEquals(0, longestCommonSubstringLength("abc", "xyz"))
+ }
+
+ @Test
+ fun longestCommonSubstring_empty_string() {
+ assertEquals(0, longestCommonSubstringLength("", "abc"))
+ assertEquals(0, longestCommonSubstringLength("abc", ""))
+ }
+
+ @Test
+ fun longestCommonSubstring_case_sensitive() {
+ // The function itself is case-sensitive; callers lowercase
+ assertEquals(0, longestCommonSubstringLength("ABC", "abc"))
+ }
+
+ @Test
+ fun longestCommonSubstring_longer_second() {
+ assertEquals(4, longestCommonSubstringLength("test", "this is a test string"))
+ }
+
+ // NodeNameResult / ChannelNameResult sealed classes are tested indirectly via
+ // the integration with AiFunctionProviderImpl, but we verify basic structure here.
+
+ @Test
+ fun nodeNameResult_found_carries_data() {
+ val result = NodeNameResult.Found(nodeNum = 42, userId = "!abcd1234")
+ assertIs(result)
+ assertEquals(42, result.nodeNum)
+ assertEquals("!abcd1234", result.userId)
+ }
+
+ @Test
+ fun nodeNameResult_ambiguous_carries_candidates() {
+ val result = NodeNameResult.Ambiguous(listOf("Alice", "Alicia"))
+ assertIs(result)
+ assertEquals(2, result.candidates.size)
+ }
+
+ @Test
+ fun channelNameResult_found_carries_data() {
+ val result = ChannelNameResult.Found(channelIndex = 1, name = "General")
+ assertIs(result)
+ assertEquals(1, result.channelIndex)
+ assertEquals("General", result.name)
+ }
+
+ // --- Behavioral tests for resolveNodeName ---
+
+ @Test
+ fun resolveNodeName_exact_match_case_insensitive() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes =
+ mapOf(
+ 1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")),
+ 2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
+ )
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("alice")
+
+ assertIs(result)
+ assertEquals(1, result.nodeNum)
+ assertEquals("!00000001", result.userId)
+ }
+
+ @Test
+ fun resolveNodeName_fuzzy_match_single_candidate() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes =
+ mapOf(
+ 1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alexander", short_name = "AX")),
+ 2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
+ )
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Alexan")
+
+ assertIs(result)
+ assertEquals(1, result.nodeNum)
+ }
+
+ @Test
+ fun resolveNodeName_ambiguous_returns_candidates() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes =
+ mapOf(
+ 1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice Smith", short_name = "AS")),
+ 2 to Node(num = 2, user = User(id = "!00000002", long_name = "Alice Jones", short_name = "AJ")),
+ )
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Alice")
+
+ // "Alice" matches both equally via LCS
+ assertIs(result)
+ assertEquals(2, result.candidates.size)
+ }
+
+ @Test
+ fun resolveNodeName_not_found_when_no_nodes() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Unknown")
+
+ assertIs(result)
+ }
+
+ @Test
+ fun resolveNodeName_not_found_when_no_match() {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val nodes = mapOf(1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")))
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveNodeName("Zzzzzz")
+
+ assertIs(result)
+ }
+
+ // --- Behavioral tests for resolveChannelName ---
+
+ @Test
+ fun resolveChannelName_exact_match() = runTest {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val channelSet =
+ ChannelSet(settings = listOf(ChannelSettings(name = "General"), ChannelSettings(name = "Emergency")))
+ every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveChannelName("General")
+
+ assertIs(result)
+ assertEquals(0, result.channelIndex)
+ assertEquals("General", result.name)
+ }
+
+ @Test
+ fun resolveChannelName_excludes_admin_channel() = runTest {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val channelSet =
+ ChannelSet(settings = listOf(ChannelSettings(name = "admin"), ChannelSettings(name = "General")))
+ every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveChannelName("admin")
+
+ // "admin" should be excluded — cannot resolve to the admin channel
+ assertIs(result)
+ }
+
+ @Test
+ fun resolveChannelName_not_found_when_empty() = runTest {
+ val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ val channelSet = ChannelSet(settings = emptyList())
+ every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
+
+ val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
+ val result = resolver.resolveChannelName("General")
+
+ assertIs(result)
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt
new file mode 100644
index 0000000000..8d2f1ea3d9
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.ai
+
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+class RateLimiterTest {
+
+ @Test
+ fun permits_calls_under_limit() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ repeat(RateLimiter.MAX_CALLS) { assertIs(rateLimiter.tryAcquire()) }
+ }
+
+ @Test
+ fun rejects_calls_over_limit() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // Exhaust the limit
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ val result = rateLimiter.tryAcquire()
+ assertIs(result)
+ assertEquals(61, result.retryAfterSeconds) // full window remaining + 1
+ }
+
+ @Test
+ fun permits_after_window_expires() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // Exhaust the limit
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ // Advance past the window
+ clock.currentTime = Instant.fromEpochSeconds(1000) + RateLimiter.WINDOW_DURATION + 1.seconds
+
+ assertIs(rateLimiter.tryAcquire())
+ }
+
+ @Test
+ fun sliding_window_evicts_oldest_entry() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // Fill the window with calls 10 seconds apart
+ repeat(RateLimiter.MAX_CALLS) { i ->
+ clock.currentTime = Instant.fromEpochSeconds(1000L + i * 10)
+ rateLimiter.tryAcquire()
+ }
+
+ // At t=1050, first call (t=1000) is still in window (threshold is t=990)
+ clock.currentTime = Instant.fromEpochSeconds(1050)
+ assertIs(rateLimiter.tryAcquire())
+
+ // At t=1061 — first call (t=1000) should have expired from window
+ clock.currentTime = Instant.fromEpochSeconds(1061)
+ assertIs(rateLimiter.tryAcquire())
+ }
+
+ @Test
+ fun retry_after_is_accurate() = runTest {
+ val clock = FakeClock(Instant.fromEpochSeconds(1000))
+ val rateLimiter = RateLimiter(clock)
+
+ // All calls at t=1000
+ repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
+
+ // Check at t=1030 (halfway through window)
+ clock.currentTime = Instant.fromEpochSeconds(1030)
+ val result = rateLimiter.tryAcquire()
+ assertIs(result)
+ // Oldest at t=1000, expires at t=1060, now is t=1030, so retryAfter = 31
+ assertEquals(31, result.retryAfterSeconds)
+ }
+}
+
+/** Simple fake Clock for testing. */
+private class FakeClock(var currentTime: Instant) : Clock {
+ override fun now(): Instant = currentTime
+}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 964caec994..2f27056c31 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -168,6 +168,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object NodeList : SettingsRoute
+ @Serializable data object AppFunctionsSettings : SettingsRoute
+
// endregion
// region help & documentation routes
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt
new file mode 100644
index 0000000000..fd66741f7a
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.prefs.appfunctions
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+@Single
+@Suppress("TooManyFunctions")
+class AppFunctionsPrefsImpl(
+ @Named("AppDataStore") private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : AppFunctionsPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val masterEnabled: StateFlow = booleanPref(KEY_MASTER, true)
+ override val sendMessageEnabled: StateFlow = booleanPref(KEY_SEND_MESSAGE, true)
+ override val getMeshStatusEnabled: StateFlow = booleanPref(KEY_GET_MESH_STATUS, true)
+ override val getNodeListEnabled: StateFlow = booleanPref(KEY_GET_NODE_LIST, true)
+ override val getChannelInfoEnabled: StateFlow = booleanPref(KEY_GET_CHANNEL_INFO, true)
+ override val getDeviceStatusEnabled: StateFlow = booleanPref(KEY_GET_DEVICE_STATUS, true)
+ override val getNodeDetailsEnabled: StateFlow = booleanPref(KEY_GET_NODE_DETAILS, true)
+ override val getMeshMetricsEnabled: StateFlow = booleanPref(KEY_GET_MESH_METRICS, true)
+ override val getRecentMessagesEnabled: StateFlow = booleanPref(KEY_GET_RECENT_MESSAGES, true)
+ override val getUnreadSummaryEnabled: StateFlow = booleanPref(KEY_GET_UNREAD_SUMMARY, true)
+
+ override fun setMasterEnabled(enabled: Boolean) = set(KEY_MASTER, enabled)
+
+ override fun setSendMessageEnabled(enabled: Boolean) = set(KEY_SEND_MESSAGE, enabled)
+
+ override fun setGetMeshStatusEnabled(enabled: Boolean) = set(KEY_GET_MESH_STATUS, enabled)
+
+ override fun setGetNodeListEnabled(enabled: Boolean) = set(KEY_GET_NODE_LIST, enabled)
+
+ override fun setGetChannelInfoEnabled(enabled: Boolean) = set(KEY_GET_CHANNEL_INFO, enabled)
+
+ override fun setGetDeviceStatusEnabled(enabled: Boolean) = set(KEY_GET_DEVICE_STATUS, enabled)
+
+ override fun setGetNodeDetailsEnabled(enabled: Boolean) = set(KEY_GET_NODE_DETAILS, enabled)
+
+ override fun setGetMeshMetricsEnabled(enabled: Boolean) = set(KEY_GET_MESH_METRICS, enabled)
+
+ override fun setGetRecentMessagesEnabled(enabled: Boolean) = set(KEY_GET_RECENT_MESSAGES, enabled)
+
+ override fun setGetUnreadSummaryEnabled(enabled: Boolean) = set(KEY_GET_UNREAD_SUMMARY, enabled)
+
+ private fun booleanPref(key: Preferences.Key, default: Boolean): StateFlow =
+ dataStore.data.map { it[key] ?: default }.stateIn(scope, SharingStarted.Eagerly, default)
+
+ private fun set(key: Preferences.Key, value: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[key] = value } }
+ }
+
+ companion object {
+ private val KEY_MASTER = booleanPreferencesKey("appfn_master_enabled")
+ private val KEY_SEND_MESSAGE = booleanPreferencesKey("appfn_send_message")
+ private val KEY_GET_MESH_STATUS = booleanPreferencesKey("appfn_get_mesh_status")
+ private val KEY_GET_NODE_LIST = booleanPreferencesKey("appfn_get_node_list")
+ private val KEY_GET_CHANNEL_INFO = booleanPreferencesKey("appfn_get_channel_info")
+ private val KEY_GET_DEVICE_STATUS = booleanPreferencesKey("appfn_get_device_status")
+ private val KEY_GET_NODE_DETAILS = booleanPreferencesKey("appfn_get_node_details")
+ private val KEY_GET_MESH_METRICS = booleanPreferencesKey("appfn_get_mesh_metrics")
+ private val KEY_GET_RECENT_MESSAGES = booleanPreferencesKey("appfn_get_recent_messages")
+ private val KEY_GET_UNREAD_SUMMARY = booleanPreferencesKey("appfn_get_unread_summary")
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 1e0cbd29d7..c7f891a6f4 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -296,9 +296,53 @@ interface TakPrefs {
fun setTakServerEnabled(enabled: Boolean)
}
+/** Reactive interface for App Functions (system AI integration) preferences. */
+interface AppFunctionsPrefs {
+ val masterEnabled: StateFlow
+
+ fun setMasterEnabled(enabled: Boolean)
+
+ val sendMessageEnabled: StateFlow
+
+ fun setSendMessageEnabled(enabled: Boolean)
+
+ val getMeshStatusEnabled: StateFlow
+
+ fun setGetMeshStatusEnabled(enabled: Boolean)
+
+ val getNodeListEnabled: StateFlow
+
+ fun setGetNodeListEnabled(enabled: Boolean)
+
+ val getChannelInfoEnabled: StateFlow
+
+ fun setGetChannelInfoEnabled(enabled: Boolean)
+
+ val getDeviceStatusEnabled: StateFlow
+
+ fun setGetDeviceStatusEnabled(enabled: Boolean)
+
+ val getNodeDetailsEnabled: StateFlow
+
+ fun setGetNodeDetailsEnabled(enabled: Boolean)
+
+ val getMeshMetricsEnabled: StateFlow
+
+ fun setGetMeshMetricsEnabled(enabled: Boolean)
+
+ val getRecentMessagesEnabled: StateFlow
+
+ fun setGetRecentMessagesEnabled(enabled: Boolean)
+
+ val getUnreadSummaryEnabled: StateFlow
+
+ fun setGetUnreadSummaryEnabled(enabled: Boolean)
+}
+
/** Consolidated interface for all application preferences. */
interface AppPreferences {
val analytics: AnalyticsPrefs
+ val appFunctions: AppFunctionsPrefs
val homoglyph: HomoglyphPrefs
val filter: FilterPrefs
val meshLog: MeshLogPrefs
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
index 7fc17c2a9e..987c343ee7 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
@@ -44,7 +44,11 @@ import kotlin.random.Random
* This implementation is platform-agnostic and relies on injected repositories and controllers.
*/
interface SendMessageUseCase {
- suspend operator fun invoke(text: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null)
+ suspend operator fun invoke(
+ text: String,
+ contactKey: String = "0${DataPacket.ID_BROADCAST}",
+ replyId: Int? = null,
+ ): Int
}
@Suppress("TooGenericExceptionCaught")
@@ -64,7 +68,7 @@ class SendMessageUseCaseImpl(
* @param replyId Optional ID of a message being replied to.
*/
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
- override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?) {
+ override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?): Int {
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
@@ -125,7 +129,10 @@ class SendMessageUseCaseImpl(
messageQueue.enqueue(packetId)
} catch (ex: Exception) {
Logger.e(ex) { "Failed to enqueue message packet" }
+ throw ex
}
+
+ return packetId
}
private suspend fun favoriteNode(node: Node) {
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 6c59d355d7..1d32c061b5 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -86,6 +86,22 @@
Allow analytics and crash reporting.
Analytics platforms:
Any
+
+ Get channel info
+ Get device status
+ Get mesh metrics
+ Get mesh status
+ Get node details
+ Get node list
+ Get recent messages
+ Get unread summary
+ Let system AI assistants (e.g. Gemini) discover and use mesh functions
+ Allow AI access
+ Read functions
+ Send message
+ System AI
+ Control which functions are available to AI assistants
+ Write functions
App Notifications
App
Application update required
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index bef3d2be13..c83a5815a9 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.AppFunctionsPrefs
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
@@ -336,8 +337,71 @@ class FakeMeshPrefs : MeshPrefs {
}
}
+class FakeAppFunctionsPrefs : AppFunctionsPrefs {
+ override val masterEnabled = MutableStateFlow(true)
+
+ override fun setMasterEnabled(enabled: Boolean) {
+ masterEnabled.value = enabled
+ }
+
+ override val sendMessageEnabled = MutableStateFlow(true)
+
+ override fun setSendMessageEnabled(enabled: Boolean) {
+ sendMessageEnabled.value = enabled
+ }
+
+ override val getMeshStatusEnabled = MutableStateFlow(true)
+
+ override fun setGetMeshStatusEnabled(enabled: Boolean) {
+ getMeshStatusEnabled.value = enabled
+ }
+
+ override val getNodeListEnabled = MutableStateFlow(true)
+
+ override fun setGetNodeListEnabled(enabled: Boolean) {
+ getNodeListEnabled.value = enabled
+ }
+
+ override val getChannelInfoEnabled = MutableStateFlow(true)
+
+ override fun setGetChannelInfoEnabled(enabled: Boolean) {
+ getChannelInfoEnabled.value = enabled
+ }
+
+ override val getDeviceStatusEnabled = MutableStateFlow(true)
+
+ override fun setGetDeviceStatusEnabled(enabled: Boolean) {
+ getDeviceStatusEnabled.value = enabled
+ }
+
+ override val getNodeDetailsEnabled = MutableStateFlow(true)
+
+ override fun setGetNodeDetailsEnabled(enabled: Boolean) {
+ getNodeDetailsEnabled.value = enabled
+ }
+
+ override val getMeshMetricsEnabled = MutableStateFlow(true)
+
+ override fun setGetMeshMetricsEnabled(enabled: Boolean) {
+ getMeshMetricsEnabled.value = enabled
+ }
+
+ override val getRecentMessagesEnabled = MutableStateFlow(true)
+
+ override fun setGetRecentMessagesEnabled(enabled: Boolean) {
+ getRecentMessagesEnabled.value = enabled
+ }
+
+ override val getUnreadSummaryEnabled = MutableStateFlow(true)
+
+ override fun setGetUnreadSummaryEnabled(enabled: Boolean) {
+ getUnreadSummaryEnabled.value = enabled
+ }
+}
+
class FakeAppPreferences : AppPreferences {
override val analytics = FakeAnalyticsPrefs()
+ override val appFunctions = FakeAppFunctionsPrefs()
override val homoglyph = FakeHomoglyphPrefs()
override val filter = FakeFilterPrefs()
override val meshLog = FakeMeshLogPrefs()
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
index 80877834b6..84944d7bab 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
@@ -179,7 +179,7 @@ class MessageViewModelTest {
@Test
fun testSendMessage() = runTest {
- everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns Unit
+ everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 1
viewModel.sendMessage("Hello", "0!12345678", null)
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index d894e76444..d6d74cd654 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.koinInject
+import org.koin.core.qualifier.named
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
@@ -44,6 +46,8 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.WifiProvisionRoute
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_functions_settings
+import org.meshtastic.core.resources.app_functions_settings_summary
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.filter_settings
@@ -60,6 +64,7 @@ import org.meshtastic.core.ui.icon.FilterList
import org.meshtastic.core.ui.icon.HelpOutline
import org.meshtastic.core.ui.icon.List
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.SettingsRemote
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
@@ -87,6 +92,7 @@ fun SettingsScreen(
onNavigate: (Route) -> Unit = {},
onBack: (() -> Unit)? = null,
) {
+ val appFunctionsAvailable: Boolean = koinInject(qualifier = named("googleServicesAvailable"))
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
@@ -226,7 +232,7 @@ fun SettingsScreen(
// App-local settings are only relevant when configuring the local node
if (state.isLocal) {
PrivacySection(
- analyticsAvailable = state.analyticsAvailable,
+ analyticsAvailable = appFunctionsAvailable,
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(true).value,
onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
@@ -266,6 +272,18 @@ fun SettingsScreen(
}
}
+ if (appFunctionsAvailable) {
+ ExpressiveSection(title = stringResource(Res.string.app_functions_settings)) {
+ ListItem(
+ text = stringResource(Res.string.app_functions_settings),
+ supportingText = stringResource(Res.string.app_functions_settings_summary),
+ leadingIcon = MeshtasticIcons.SettingsRemote,
+ ) {
+ onNavigate(SettingsRoute.AppFunctionsSettings)
+ }
+ }
+ }
+
PersistenceSection(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsScreen.kt
new file mode 100644
index 0000000000..368150c088
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsScreen.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings.appfunctions
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_functions_get_channel_info
+import org.meshtastic.core.resources.app_functions_get_device_status
+import org.meshtastic.core.resources.app_functions_get_mesh_metrics
+import org.meshtastic.core.resources.app_functions_get_mesh_status
+import org.meshtastic.core.resources.app_functions_get_node_details
+import org.meshtastic.core.resources.app_functions_get_node_list
+import org.meshtastic.core.resources.app_functions_get_recent_messages
+import org.meshtastic.core.resources.app_functions_get_unread_summary
+import org.meshtastic.core.resources.app_functions_master_summary
+import org.meshtastic.core.resources.app_functions_master_toggle
+import org.meshtastic.core.resources.app_functions_read_section
+import org.meshtastic.core.resources.app_functions_send_message
+import org.meshtastic.core.resources.app_functions_settings
+import org.meshtastic.core.resources.app_functions_write_section
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.SettingsRemote
+
+@Composable
+fun AppFunctionsSettingsScreen(
+ viewModel: AppFunctionsSettingsViewModel,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val masterEnabled by viewModel.masterEnabled.collectAsStateWithLifecycle()
+ val sendMessage by viewModel.sendMessageEnabled.collectAsStateWithLifecycle()
+ val getMeshStatus by viewModel.getMeshStatusEnabled.collectAsStateWithLifecycle()
+ val getNodeList by viewModel.getNodeListEnabled.collectAsStateWithLifecycle()
+ val getChannelInfo by viewModel.getChannelInfoEnabled.collectAsStateWithLifecycle()
+ val getDeviceStatus by viewModel.getDeviceStatusEnabled.collectAsStateWithLifecycle()
+ val getNodeDetails by viewModel.getNodeDetailsEnabled.collectAsStateWithLifecycle()
+ val getMeshMetrics by viewModel.getMeshMetricsEnabled.collectAsStateWithLifecycle()
+ val getRecentMessages by viewModel.getRecentMessagesEnabled.collectAsStateWithLifecycle()
+ val getUnreadSummary by viewModel.getUnreadSummaryEnabled.collectAsStateWithLifecycle()
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.app_functions_settings),
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ ourNode = null,
+ showNodeChip = false,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding).verticalScroll(rememberScrollState())) {
+ MasterToggleSection(
+ masterEnabled = masterEnabled,
+ onToggle = { viewModel.setMasterEnabled(!masterEnabled) },
+ )
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ WriteFunctionsSection(
+ masterEnabled = masterEnabled,
+ sendMessage = sendMessage,
+ onToggleSendMessage = { viewModel.setSendMessageEnabled(!sendMessage) },
+ )
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ ReadFunctionsSection(
+ masterEnabled = masterEnabled,
+ getMeshStatus = getMeshStatus,
+ onToggleMeshStatus = { viewModel.setGetMeshStatusEnabled(!getMeshStatus) },
+ getNodeList = getNodeList,
+ onToggleNodeList = { viewModel.setGetNodeListEnabled(!getNodeList) },
+ getChannelInfo = getChannelInfo,
+ onToggleChannelInfo = { viewModel.setGetChannelInfoEnabled(!getChannelInfo) },
+ getDeviceStatus = getDeviceStatus,
+ onToggleDeviceStatus = { viewModel.setGetDeviceStatusEnabled(!getDeviceStatus) },
+ getNodeDetails = getNodeDetails,
+ onToggleNodeDetails = { viewModel.setGetNodeDetailsEnabled(!getNodeDetails) },
+ getMeshMetrics = getMeshMetrics,
+ onToggleMeshMetrics = { viewModel.setGetMeshMetricsEnabled(!getMeshMetrics) },
+ getRecentMessages = getRecentMessages,
+ onToggleRecentMessages = { viewModel.setGetRecentMessagesEnabled(!getRecentMessages) },
+ getUnreadSummary = getUnreadSummary,
+ onToggleUnreadSummary = { viewModel.setGetUnreadSummaryEnabled(!getUnreadSummary) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun MasterToggleSection(masterEnabled: Boolean, onToggle: () -> Unit) {
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_master_toggle),
+ checked = masterEnabled,
+ leadingIcon = MeshtasticIcons.SettingsRemote,
+ onClick = onToggle,
+ )
+ Text(
+ text = stringResource(Res.string.app_functions_master_summary),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp),
+ )
+}
+
+@Composable
+private fun WriteFunctionsSection(masterEnabled: Boolean, sendMessage: Boolean, onToggleSendMessage: () -> Unit) {
+ Text(
+ text = stringResource(Res.string.app_functions_write_section),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_send_message),
+ checked = sendMessage,
+ enabled = masterEnabled,
+ onClick = onToggleSendMessage,
+ )
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun ReadFunctionsSection(
+ masterEnabled: Boolean,
+ getMeshStatus: Boolean,
+ onToggleMeshStatus: () -> Unit,
+ getNodeList: Boolean,
+ onToggleNodeList: () -> Unit,
+ getChannelInfo: Boolean,
+ onToggleChannelInfo: () -> Unit,
+ getDeviceStatus: Boolean,
+ onToggleDeviceStatus: () -> Unit,
+ getNodeDetails: Boolean,
+ onToggleNodeDetails: () -> Unit,
+ getMeshMetrics: Boolean,
+ onToggleMeshMetrics: () -> Unit,
+ getRecentMessages: Boolean,
+ onToggleRecentMessages: () -> Unit,
+ getUnreadSummary: Boolean,
+ onToggleUnreadSummary: () -> Unit,
+) {
+ Text(
+ text = stringResource(Res.string.app_functions_read_section),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_mesh_status),
+ checked = getMeshStatus,
+ enabled = masterEnabled,
+ onClick = onToggleMeshStatus,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_node_list),
+ checked = getNodeList,
+ enabled = masterEnabled,
+ onClick = onToggleNodeList,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_channel_info),
+ checked = getChannelInfo,
+ enabled = masterEnabled,
+ onClick = onToggleChannelInfo,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_device_status),
+ checked = getDeviceStatus,
+ enabled = masterEnabled,
+ onClick = onToggleDeviceStatus,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_node_details),
+ checked = getNodeDetails,
+ enabled = masterEnabled,
+ onClick = onToggleNodeDetails,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_mesh_metrics),
+ checked = getMeshMetrics,
+ enabled = masterEnabled,
+ onClick = onToggleMeshMetrics,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_recent_messages),
+ checked = getRecentMessages,
+ enabled = masterEnabled,
+ onClick = onToggleRecentMessages,
+ )
+ SwitchListItem(
+ text = stringResource(Res.string.app_functions_get_unread_summary),
+ checked = getUnreadSummary,
+ enabled = masterEnabled,
+ onClick = onToggleUnreadSummary,
+ )
+}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsViewModel.kt
new file mode 100644
index 0000000000..4069f63d9e
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/appfunctions/AppFunctionsSettingsViewModel.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings.appfunctions
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.StateFlow
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.repository.AppFunctionsPrefs
+
+@KoinViewModel
+class AppFunctionsSettingsViewModel(private val prefs: AppFunctionsPrefs) : ViewModel() {
+
+ val masterEnabled: StateFlow = prefs.masterEnabled
+ val sendMessageEnabled: StateFlow = prefs.sendMessageEnabled
+ val getMeshStatusEnabled: StateFlow = prefs.getMeshStatusEnabled
+ val getNodeListEnabled: StateFlow = prefs.getNodeListEnabled
+ val getChannelInfoEnabled: StateFlow = prefs.getChannelInfoEnabled
+ val getDeviceStatusEnabled: StateFlow = prefs.getDeviceStatusEnabled
+ val getNodeDetailsEnabled: StateFlow = prefs.getNodeDetailsEnabled
+ val getMeshMetricsEnabled: StateFlow = prefs.getMeshMetricsEnabled
+ val getRecentMessagesEnabled: StateFlow = prefs.getRecentMessagesEnabled
+ val getUnreadSummaryEnabled: StateFlow = prefs.getUnreadSummaryEnabled
+
+ fun setMasterEnabled(enabled: Boolean) = prefs.setMasterEnabled(enabled)
+
+ fun setSendMessageEnabled(enabled: Boolean) = prefs.setSendMessageEnabled(enabled)
+
+ fun setGetMeshStatusEnabled(enabled: Boolean) = prefs.setGetMeshStatusEnabled(enabled)
+
+ fun setGetNodeListEnabled(enabled: Boolean) = prefs.setGetNodeListEnabled(enabled)
+
+ fun setGetChannelInfoEnabled(enabled: Boolean) = prefs.setGetChannelInfoEnabled(enabled)
+
+ fun setGetDeviceStatusEnabled(enabled: Boolean) = prefs.setGetDeviceStatusEnabled(enabled)
+
+ fun setGetNodeDetailsEnabled(enabled: Boolean) = prefs.setGetNodeDetailsEnabled(enabled)
+
+ fun setGetMeshMetricsEnabled(enabled: Boolean) = prefs.setGetMeshMetricsEnabled(enabled)
+
+ fun setGetRecentMessagesEnabled(enabled: Boolean) = prefs.setGetRecentMessagesEnabled(enabled)
+
+ fun setGetUnreadSummaryEnabled(enabled: Boolean) = prefs.setGetUnreadSummaryEnabled(enabled)
+}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
index fb300eb4fa..1225b8fc54 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
@@ -36,6 +36,8 @@ import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.NodeListScreen
import org.meshtastic.feature.settings.SettingsViewModel
+import org.meshtastic.feature.settings.appfunctions.AppFunctionsSettingsScreen
+import org.meshtastic.feature.settings.appfunctions.AppFunctionsSettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.debugging.DebugViewModel
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
@@ -250,6 +252,11 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
+
+ entry {
+ val viewModel: AppFunctionsSettingsViewModel = koinViewModel()
+ AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
+ }
}
/** Expect declaration for the platform-specific settings main screen. */
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 60c965fe48..c8ad52e29b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,6 +5,7 @@ xmlutil = "0.91.3"
agp = "9.2.1"
appcompat = "1.7.1"
accompanist = "0.37.3"
+appfunctions = "1.0.0-alpha09"
# androidx
datastore = "1.2.1"
@@ -103,6 +104,9 @@ xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", v
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.13.0" }
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.10.0" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
+androidx-appfunctions = { module = "androidx.appfunctions:appfunctions", version.ref = "appfunctions" }
+androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" }
+androidx-appfunctions-service = { module = "androidx.appfunctions:appfunctions-service", version.ref = "appfunctions" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
diff --git a/specs/20260521-091500-app-functions/checklist.md b/specs/20260521-091500-app-functions/checklist.md
new file mode 100644
index 0000000000..10a0875c32
--- /dev/null
+++ b/specs/20260521-091500-app-functions/checklist.md
@@ -0,0 +1,97 @@
+# Implementation Checklist: Android App Functions Integration
+
+> Auto-generated from `specs/20260521-091500-app-functions/spec.md`
+
+## Pre-Implementation
+
+- [ ] **Read skill docs**: `.skills/kmp-architecture/SKILL.md` for source-set rules
+- [ ] **Bootstrap**: Run `git submodule update --init && [ -f local.properties ] || cp secrets.defaults.properties local.properties`
+- [ ] **Baseline verification**: `./gradlew spotlessApply detekt assembleDebug test allTests` passes before any changes
+- [ ] **Confirm compileSdk**: Check current `compileSdk` in `build-logic/` — must be ≥ 36 for AppFunctions
+- [ ] **Confirm KSP setup**: Verify KSP plugin is already applied in `androidApp/build.gradle.kts`
+
+## Dependencies & Build Configuration
+
+- [ ] Add `androidx.appfunctions:appfunctions:1.0.0-alpha09` to androidApp dependencies
+- [ ] Add `androidx.appfunctions:appfunctions-service:1.0.0-alpha09` to androidApp dependencies
+- [ ] Add `androidx.appfunctions:appfunctions-compiler:1.0.0-alpha09` as KSP processor
+- [ ] Add `ksp { arg("appfunctions:aggregateAppFunctions", "true") }` to androidApp build config
+- [ ] Bump `compileSdk` to 36 if not already (check build-logic conventions plugin)
+- [ ] Verify build compiles: `./gradlew :androidApp:compileGoogleDebugKotlin`
+
+## commonMain: Platform-Agnostic Contracts (`core/data`)
+
+- [ ] Create `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/` package
+- [ ] **AiFunctionProvider.kt**: Interface with `sendMessage()` and `getMeshStatus()` suspend functions
+- [ ] **AiFunctionResult.kt**: Sealed class hierarchy for success/error results (no Android dependencies!)
+- [ ] **FuzzyNameResolver.kt**: Longest-substring matching logic; returns single match or throws with candidates
+- [ ] **RateLimiter.kt**: Token-bucket implementation (5 tokens, 60s refill); use `kotlinx.datetime` or `Clock` for time
+- [ ] Unit tests for `FuzzyNameResolver` — exact match, single fuzzy match, ambiguous match, no match
+- [ ] Unit tests for `RateLimiter` — under limit, at limit, over limit, refill after window
+- [ ] Verify no `android.*` or `java.*` imports in any commonMain files
+- [ ] Run: `./gradlew :core:data:allTests`
+
+## commonMain: AiFunctionProvider Implementation
+
+- [ ] Create `AiFunctionProviderImpl.kt` wiring to existing repositories
+- [ ] Inject `NodeRepository`, `ServiceRepository`, `CommandSender`, `RadioConfigRepository` via constructor
+- [ ] `sendMessage`: Check connection → rate limit → resolve name → validate length → send → return result
+- [ ] `getMeshStatus`: Read connection state, node counts, battery from existing flows (`.first()`)
+- [ ] Register in Koin module (`core/data` DI module)
+- [ ] Integration test: `AiFunctionProviderImpl` with mocked repositories
+
+## androidApp: App Function Declarations (Google flavor)
+
+- [ ] Create `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/` package
+- [ ] **MeshtasticAppFunctions.kt**: Class with `@AppFunction(isDescribedByKDoc = true)` methods
+ - [ ] `sendMessage(appFunctionContext, text, recipientName?, channelName?)` → `SendMessageResponse`
+ - [ ] `getMeshStatus(appFunctionContext)` → `MeshStatusResponse`
+- [ ] **models/SendMessageResponse.kt**: `@AppFunctionSerializable` with messageId, timestamp, channel
+- [ ] **models/MeshStatusResponse.kt**: `@AppFunctionSerializable` with connectionState, onlineNodes, totalNodes, batteryLevel
+- [ ] **AppFunctionFactory.kt**: `AppFunctionConfiguration.Provider` using Koin to resolve `AiFunctionProviderImpl`
+- [ ] Register `AppFunctionConfiguration.Provider` in `GoogleMeshUtilApplication` (Google flavor subclass)
+- [ ] KDoc on every `@AppFunction` method — clear enough for AI agent to understand without context
+- [ ] KDoc on every `@AppFunctionSerializable` field — descriptive for schema generation
+
+## Error Handling
+
+- [ ] Disconnected state → throw `AppFunctionAppException("Not connected to a Meshtastic radio")`
+- [ ] Ambiguous name match → throw `AppFunctionInvalidArgumentException` with candidate list in message
+- [ ] No name match → throw `AppFunctionElementNotFoundException`
+- [ ] Message too long → throw `AppFunctionInvalidArgumentException` with max length info
+- [ ] Rate limit exceeded → throw `AppFunctionLimitExceededException`
+- [ ] Timeout (>5s) → throw `AppFunctionCancelledException`
+- [ ] No generic `Exception` or `RuntimeException` thrown from AppFunction methods
+
+## Security & Privacy
+
+- [ ] No admin channel data exposed in any response
+- [ ] No encryption keys or PSK material in responses
+- [ ] No raw protobuf payloads returned — only structured, safe data
+- [ ] No PII beyond what user has already shared on mesh (node names, messages are user-consented)
+- [ ] Rate limiter prevents AI-driven mesh flooding
+
+## Testing & Verification
+
+- [ ] `./gradlew :core:data:allTests` — commonMain unit tests pass
+- [ ] `./gradlew :androidApp:testGoogleDebugUnitTest` — Android unit tests pass
+- [ ] `./gradlew :androidApp:assembleGoogleDebug` — builds successfully
+- [ ] `./gradlew spotlessApply spotlessCheck` — formatting passes
+- [ ] `./gradlew detekt` — static analysis passes
+- [ ] `adb shell cmd app_function list-app-functions | grep org.meshtastic` — functions registered on device
+- [ ] Manual test: invoke via test agent app on API 35+ device/emulator
+- [ ] Verify rate limiting works (5 rapid calls → exception on 6th)
+- [ ] Verify disconnected state returns proper error (not crash)
+
+## Documentation
+
+- [ ] KDoc comprehensive on all public APIs
+- [ ] Update spec status from "Draft" to "Implemented" after verification
+- [ ] Add entry to CHANGELOG.md under next release
+
+## Final Verification
+
+- [ ] Full verification pass: `./gradlew spotlessApply detekt assembleDebug test allTests`
+- [ ] No regressions in existing tests
+- [ ] PR description references spec: `specs/20260521-091500-app-functions/spec.md`
+- [ ] Branch naming follows convention
diff --git a/specs/20260521-091500-app-functions/plan.md b/specs/20260521-091500-app-functions/plan.md
new file mode 100644
index 0000000000..cd80baf740
--- /dev/null
+++ b/specs/20260521-091500-app-functions/plan.md
@@ -0,0 +1,243 @@
+# Implementation Plan: Android App Functions Integration
+
+**Spec**: `specs/20260521-091500-app-functions/spec.md`
+**Branch**: `jamesarich/crispy-barnacle`
+**Created**: 2026-05-21
+
+## Overview
+
+Implement a minimal MVP (2 App Functions: `sendMessage` + `getMeshStatus`) to validate the Meshtastic ↔ Android system AI integration pattern. The architecture follows KMP conventions: platform-agnostic interfaces + logic in `commonMain`, Android-specific `@AppFunction` wiring in the Google flavor.
+
+## Key Findings from Exploration
+
+- **compileSdk = 37** (already satisfies the ≥36 requirement)
+- **Koin uses its own compiler plugin** (not KSP) — AppFunctions KSP processor is separate and needs the `com.google.devtools.ksp` Gradle plugin applied to `androidApp`
+- **Google flavor already has `ai/` package** with `GeminiNanoDocAssistant.kt` and `GoogleAiModule.kt` in DI
+- **`FlavorModule.kt`** includes `GoogleAiModule` — we'll add our AppFunctions module here
+- **Application class** (`MeshUtilApplication`) already implements `Configuration.Provider` — we'll add `AppFunctionConfiguration.Provider`
+- **`CommandSender.sendData(DataPacket)`** is the method to send messages
+- **`DataPacket`** uses `channel: Int` (index) and `to: String?` (nodeID or `ID_BROADCAST`)
+- **`NodeRepository`** has `nodeDBbyNum: StateFlow