feat(ai): Add App Functions for system AI integration (#5585)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-06-03 16:24:17 -05:00
committed by James Rich
parent 7e51f33325
commit 51fa718d89
37 changed files with 4471 additions and 8 deletions

View File

@@ -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

View File

@@ -32,6 +32,7 @@ plugins {
alias(libs.plugins.secrets)
id("meshtastic.aboutlibraries")
id("dev.mokkery")
alias(libs.plugins.devtools.ksp)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -178,6 +179,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") }
@@ -283,6 +286,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)

View File

@@ -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
}

View File

@@ -16,12 +16,18 @@
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<application
android:name="org.meshtastic.app.GoogleMeshUtilApplication"
tools:replace="android:name">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
</application>
</manifest>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<MeshtasticAppFunctions>()
}
.build()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NodeInfo>,
)
/** 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<ChannelInfo>,
)
/** 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<MessageInfo>,
)
/** 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<ContactUnreadInfo>,
)
/** 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?,
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean>)
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<Pair<String, Boolean>>) {
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"
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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 (150). 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)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@@ -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

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
appfn:description="Meshtastic is a mesh radio networking app for off-grid communications using LoRa devices.
Operational Patterns:
- Use 'getMeshStatus' to verify the radio is connected before calling functions that require a live connection.
- Use 'getNodeList' or 'getNodeDetails' to resolve node names and IDs before calling 'sendMessage' with a recipientName.
- Use 'getChannelInfo' to discover available channel names before sending broadcast messages to non-primary channels.
- 'getRecentMessages' and 'getUnreadSummary' work offline from local message history and do not require an active connection.
- Prefer 'getUnreadSummary' for a quick overview, then 'getRecentMessages' with a contactName filter for details.
Constraints:
- Message text is limited to 237 bytes.
- Node and channel name resolution is fuzzy (partial matches accepted).
- Rate limit: 10 messages per 60 seconds via 'sendMessage'.
- Message history limit: 1-50 messages per query (default 20)."
appfn:displayDescription="@string/app_description" />

View File

@@ -17,4 +17,5 @@
-->
<resources>
<string name="app_name">Meshtastic</string>
<string name="app_description">Off-grid mesh networking for secure, long-range communications via LoRa radio.</string>
</resources>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<AppFunctionInvalidArgumentException> {
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<AppFunctionNotSupportedException> { 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<AppFunctionElementNotFoundException> { 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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>) : 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"
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>) : 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<NodeSummary>) : 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<ChannelSummary>) : 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<MessageSummary>) : 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<ContactUnread>,
)
/** 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?,
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NameCandidate>): 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<String>) : NodeNameResult()
data object NotFound : NodeNameResult()
}
sealed class ChannelNameResult {
data class Found(val channelIndex: Int, val name: String) : ChannelNameResult()
data class Ambiguous(val candidates: List<String>) : ChannelNameResult()
data object NotFound : ChannelNameResult()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Instant>(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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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>(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<GetNodeDetailsResult.NotConnected>(result)
}
@Test
fun getNodeDetails_returns_not_found_for_unknown_node() = runTest {
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
every { nodeRepository.nodeDBbyNum } returns nodeMap
val provider = createProvider()
val result = provider.getNodeDetails("!ffffff")
assertIs<GetNodeDetailsResult.NotFound>(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<GetNodeDetailsResult.Success>(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<GetNodeDetailsResult.Success>(result)
assertNull(result.node.latitude)
assertNull(result.node.longitude)
}
@Test
fun getNodeDetails_returns_error_for_invalid_hex_format() = runTest {
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
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<GetMeshMetricsResult.NotConnected>(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<GetMeshMetricsResult.Success>(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<Int, Node>())
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<GetMeshMetricsResult.Success>(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<GetMeshMetricsResult.Success>(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<GetMeshMetricsResult.Success>(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<SendMessageResult.NotConnected>(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<SendMessageResult.RateLimited>(result)
}
// --- getRecentMessages tests ---
@Test
fun getRecentMessages_contact_not_found() = runTest {
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
every { nodeRepository.nodeDBbyNum } returns nodeMap
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
val provider = createProvider()
val result = provider.getRecentMessages("NonExistent", 10)
assertIs<GetRecentMessagesResult.ContactNotFound>(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<GetUnreadSummaryResult.Success>(result)
assertEquals(0, result.summary.totalUnreadCount)
assertEquals(0, result.summary.contacts.size)
}
}
private class TestClock(var currentTime: Instant) : Clock {
override fun now(): Instant = currentTime
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NodeNameResult.Found>(result)
assertEquals(42, result.nodeNum)
assertEquals("!abcd1234", result.userId)
}
@Test
fun nodeNameResult_ambiguous_carries_candidates() {
val result = NodeNameResult.Ambiguous(listOf("Alice", "Alicia"))
assertIs<NodeNameResult.Ambiguous>(result)
assertEquals(2, result.candidates.size)
}
@Test
fun channelNameResult_found_carries_data() {
val result = ChannelNameResult.Found(channelIndex = 1, name = "General")
assertIs<ChannelNameResult.Found>(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<NodeNameResult.Found>(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<NodeNameResult.Found>(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<NodeNameResult.Ambiguous>(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<NodeNameResult.NotFound>(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<NodeNameResult.NotFound>(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<ChannelNameResult.Found>(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<ChannelNameResult.NotFound>(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<ChannelNameResult.NotFound>(result)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<RateLimitResult.Permitted>(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<RateLimitResult.Limited>(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<RateLimitResult.Permitted>(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<RateLimitResult.Limited>(rateLimiter.tryAcquire())
// At t=1061 — first call (t=1000) should have expired from window
clock.currentTime = Instant.fromEpochSeconds(1061)
assertIs<RateLimitResult.Permitted>(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<RateLimitResult.Limited>(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
}

View File

@@ -168,6 +168,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object NodeList : SettingsRoute
@Serializable data object AppFunctionsSettings : SettingsRoute
// endregion
// region help & documentation routes

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences>,
dispatchers: CoroutineDispatchers,
) : AppFunctionsPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val masterEnabled: StateFlow<Boolean> = booleanPref(KEY_MASTER, true)
override val sendMessageEnabled: StateFlow<Boolean> = booleanPref(KEY_SEND_MESSAGE, true)
override val getMeshStatusEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_MESH_STATUS, true)
override val getNodeListEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_NODE_LIST, true)
override val getChannelInfoEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_CHANNEL_INFO, true)
override val getDeviceStatusEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_DEVICE_STATUS, true)
override val getNodeDetailsEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_NODE_DETAILS, true)
override val getMeshMetricsEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_MESH_METRICS, true)
override val getRecentMessagesEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_RECENT_MESSAGES, true)
override val getUnreadSummaryEnabled: StateFlow<Boolean> = 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<Boolean>, default: Boolean): StateFlow<Boolean> =
dataStore.data.map { it[key] ?: default }.stateIn(scope, SharingStarted.Eagerly, default)
private fun set(key: Preferences.Key<Boolean>, 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")
}
}

View File

@@ -296,9 +296,53 @@ interface TakPrefs {
fun setTakServerEnabled(enabled: Boolean)
}
/** Reactive interface for App Functions (system AI integration) preferences. */
interface AppFunctionsPrefs {
val masterEnabled: StateFlow<Boolean>
fun setMasterEnabled(enabled: Boolean)
val sendMessageEnabled: StateFlow<Boolean>
fun setSendMessageEnabled(enabled: Boolean)
val getMeshStatusEnabled: StateFlow<Boolean>
fun setGetMeshStatusEnabled(enabled: Boolean)
val getNodeListEnabled: StateFlow<Boolean>
fun setGetNodeListEnabled(enabled: Boolean)
val getChannelInfoEnabled: StateFlow<Boolean>
fun setGetChannelInfoEnabled(enabled: Boolean)
val getDeviceStatusEnabled: StateFlow<Boolean>
fun setGetDeviceStatusEnabled(enabled: Boolean)
val getNodeDetailsEnabled: StateFlow<Boolean>
fun setGetNodeDetailsEnabled(enabled: Boolean)
val getMeshMetricsEnabled: StateFlow<Boolean>
fun setGetMeshMetricsEnabled(enabled: Boolean)
val getRecentMessagesEnabled: StateFlow<Boolean>
fun setGetRecentMessagesEnabled(enabled: Boolean)
val getUnreadSummaryEnabled: StateFlow<Boolean>
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

View File

@@ -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) {

View File

@@ -86,6 +86,22 @@
<string name="analytics_okay">Allow analytics and crash reporting.</string>
<string name="analytics_platforms">Analytics platforms:</string>
<string name="any">Any</string>
<!-- APP -->
<string name="app_functions_get_channel_info">Get channel info</string>
<string name="app_functions_get_device_status">Get device status</string>
<string name="app_functions_get_mesh_metrics">Get mesh metrics</string>
<string name="app_functions_get_mesh_status">Get mesh status</string>
<string name="app_functions_get_node_details">Get node details</string>
<string name="app_functions_get_node_list">Get node list</string>
<string name="app_functions_get_recent_messages">Get recent messages</string>
<string name="app_functions_get_unread_summary">Get unread summary</string>
<string name="app_functions_master_summary">Let system AI assistants (e.g. Gemini) discover and use mesh functions</string>
<string name="app_functions_master_toggle">Allow AI access</string>
<string name="app_functions_read_section">Read functions</string>
<string name="app_functions_send_message">Send message</string>
<string name="app_functions_settings">System AI</string>
<string name="app_functions_settings_summary">Control which functions are available to AI assistants</string>
<string name="app_functions_write_section">Write functions</string>
<string name="app_notifications">App Notifications</string>
<string name="app_settings">App</string>
<string name="app_too_old">Application update required</string>

View File

@@ -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()

View File

@@ -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)

View File

@@ -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) },

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = prefs.masterEnabled
val sendMessageEnabled: StateFlow<Boolean> = prefs.sendMessageEnabled
val getMeshStatusEnabled: StateFlow<Boolean> = prefs.getMeshStatusEnabled
val getNodeListEnabled: StateFlow<Boolean> = prefs.getNodeListEnabled
val getChannelInfoEnabled: StateFlow<Boolean> = prefs.getChannelInfoEnabled
val getDeviceStatusEnabled: StateFlow<Boolean> = prefs.getDeviceStatusEnabled
val getNodeDetailsEnabled: StateFlow<Boolean> = prefs.getNodeDetailsEnabled
val getMeshMetricsEnabled: StateFlow<Boolean> = prefs.getMeshMetricsEnabled
val getRecentMessagesEnabled: StateFlow<Boolean> = prefs.getRecentMessagesEnabled
val getUnreadSummaryEnabled: StateFlow<Boolean> = 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)
}

View File

@@ -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
@@ -248,6 +250,11 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
entry<SettingsRoute.AppFunctionsSettings> {
val viewModel: AppFunctionsSettingsViewModel = koinViewModel()
AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
}
}
/** Expect declaration for the platform-specific settings main screen. */

View File

@@ -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" }

View File

@@ -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

View File

@@ -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<Map<Int, Node>>` and `getNodes()` with filter
- **`ServiceRepository.connectionState: StateFlow<ConnectionState>`** for connection status
## Implementation Phases
### Phase 1: Dependencies & Build Setup
**Files to modify:**
- `gradle/libs.versions.toml` — add AppFunctions library versions
- `androidApp/build.gradle.kts` — apply KSP plugin, add AppFunctions dependencies
**Details:**
```toml
# libs.versions.toml
appfunctions = "1.0.0-alpha09"
# libraries
androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" }
androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" }
androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" }
```
In `androidApp/build.gradle.kts`:
- Apply `com.google.devtools.ksp` plugin
- Add `implementation(libs.androidx.appfunctions)` and `implementation(libs.androidx.appfunctions.service)`
- Add `ksp(libs.androidx.appfunctions.compiler)`
- Add `ksp { arg("appfunctions:aggregateAppFunctions", "true") }`
---
### Phase 2: commonMain Contracts & Utilities (`core/data`)
**New files:**
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt`
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt`
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt`
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt`
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt`
- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt`
- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt`
- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt`
**AiFunctionProvider interface:**
```kotlin
package org.meshtastic.core.data.ai
interface AiFunctionProvider {
/** Send a text message to a channel or node resolved by name. */
suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult
/** Get current mesh network status. */
suspend fun getMeshStatus(): MeshStatusResult
}
```
**AiFunctionResult sealed types:**
```kotlin
sealed class SendMessageResult {
data class Success(val messageId: Int, val channel: String, val timestamp: Long) : SendMessageResult()
data class NotConnected(val message: String) : SendMessageResult()
data class AmbiguousName(val candidates: List<String>) : SendMessageResult()
data class InvalidArgument(val reason: String) : SendMessageResult()
data class RateLimited(val retryAfterSeconds: Int) : SendMessageResult()
}
data class MeshStatusResult(
val connectionState: String,
val onlineNodeCount: Int,
val totalNodeCount: Int,
val localBatteryLevel: Int?,
val localNodeName: String?,
)
```
**FuzzyNameResolver:**
- Takes a query string and a list of candidate names
- Uses longest common substring for matching
- Returns: single match (exact or unique fuzzy) or error with candidate list
- Case-insensitive comparison
- Also resolves channel names from `RadioConfigRepository` channel set
**RateLimiter:**
- Sliding window: tracks last 5 invocation timestamps, rejects if all within 60s
- Uses `kotlinx.datetime.Clock` (or injected `Clock` from existing `CoreDataModule`)
- Thread-safe via `Mutex` (already used in project for commonMain concurrency)
**AiFunctionProviderImpl:**
- `@Single` Koin annotation
- Constructor-injects: `NodeRepository`, `ServiceRepository`, `CommandSender`, `RadioConfigRepository`, `FuzzyNameResolver`, `RateLimiter`, `Clock`
- `sendMessage`: check connection → check rate → resolve name → validate length → create `DataPacket``commandSender.sendData()` → return success
- `getMeshStatus`: read `connectionState.value`, `onlineNodeCount.first()`, `totalNodeCount.first()`, `ourNodeInfo.value?.batteryLevel`
---
### Phase 3: Android App Function Declarations (Google flavor)
**New files:**
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/MeshtasticAppFunctions.kt`
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/SendMessageResponse.kt`
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/MeshStatusResponse.kt`
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/di/AppFunctionsModule.kt`
**Modify:**
- `androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt` — include `AppFunctionsModule`
- `androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` — add `AppFunctionConfiguration.Provider`
**MeshtasticAppFunctions:**
```kotlin
@Suppress("unused") // Invoked by system via AppFunctionManager
class MeshtasticAppFunctions(
private val provider: AiFunctionProvider
) {
/**
* Send a text message over the Meshtastic mesh network.
*
* Messages are broadcast to all nodes on a channel, or sent directly to a
* specific node. The recipient is resolved by name using fuzzy matching.
*
* @param appFunctionContext The execution context provided by the system.
* @param text The message text to send (max 237 characters for standard mesh).
* @param recipientName Optional node name for direct messages. Omit for channel broadcast.
* @param channelName Optional channel name to send on. Defaults to primary channel if omitted.
* @return Confirmation with message ID, channel name, and send timestamp.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun sendMessage(
appFunctionContext: AppFunctionContext,
text: String,
recipientName: String? = null,
channelName: String? = null,
): SendMessageResponse { ... }
/**
* Get the current status of the Meshtastic mesh network.
*
* Returns connection state, number of online and total nodes in the mesh,
* local device battery level, and the local node's display name.
*
* @param appFunctionContext The execution context provided by the system.
* @return Current mesh network status summary.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun getMeshStatus(
appFunctionContext: AppFunctionContext,
): MeshStatusResponse { ... }
}
```
**AppFunctionConfiguration.Provider** in Application:
```kotlin
// In MeshUtilApplication (or subclass in google flavor)
override val appFunctionConfiguration: AppFunctionConfiguration
get() = AppFunctionConfiguration.Builder()
.addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
MeshtasticAppFunctions(get<AiFunctionProvider>())
}
.build()
```
**Note**: Since the Application class is in `src/main/` (shared), but `AppFunctionConfiguration.Provider` is Android 16+, we need to handle this carefully. Options:
1. Make google-flavor `GoogleMeshUtilApplication` extend `MeshUtilApplication` and add the provider there
2. Use a conditional check in the base class
**Decision**: Use option 1 — a `GoogleMeshUtilApplication` subclass in the Google flavor that adds `AppFunctionConfiguration.Provider`. This keeps the base class clean and the fdroid flavor unaffected.
---
### Phase 4: Error Mapping
In `MeshtasticAppFunctions`, map `AiFunctionResult` sealed types to platform exceptions:
- `SendMessageResult.NotConnected``AppFunctionAppException("Not connected...")`
- `SendMessageResult.AmbiguousName``AppFunctionInvalidArgumentException("Multiple matches: ...")`
- `SendMessageResult.InvalidArgument``AppFunctionInvalidArgumentException(...)`
- `SendMessageResult.RateLimited``AppFunctionLimitExceededException(...)`
---
### Phase 5: Testing & Verification
1. **Unit tests** (commonMain):
- `FuzzyNameResolverTest` — exact, fuzzy, ambiguous, no-match cases
- `RateLimiterTest` — permits, exhaustion, refill
- `AiFunctionProviderImplTest` — happy path, disconnected, rate limited, ambiguous
2. **Build verification**:
- `./gradlew :core:data:allTests`
- `./gradlew :androidApp:assembleGoogleDebug`
- `./gradlew spotlessApply detekt`
- `./gradlew test allTests`
3. **On-device verification** (manual):
- `adb shell cmd app_function list-app-functions | grep org.meshtastic`
## Risk Assessment
| Risk | Mitigation |
|------|-----------|
| AppFunctions alpha library has breaking API changes | Pin to `1.0.0-alpha09`; isolate behind our own interface |
| KSP plugin conflicts with existing Koin compiler | KSP and Koin compiler are independent; Koin uses its own Gradle plugin |
| `AppFunctionConfiguration.Provider` on Application conflicts with `Configuration.Provider` | Use flavor subclass approach |
| Rate limiter state lost on process death | Acceptable — resets on app restart; mesh flooding concern is per-session |
| Fuzzy matching too permissive/restrictive | Tunable threshold; start conservative (require ≥50% substring match) |
## File Change Summary
| Action | File |
|--------|------|
| Modify | `gradle/libs.versions.toml` |
| Modify | `androidApp/build.gradle.kts` |
| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt` |
| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt` |
| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt` |
| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt` |
| Create | `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt` |
| Create | `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt` |
| Create | `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt` |
| Create | `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt` |
| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/MeshtasticAppFunctions.kt` |
| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/SendMessageResponse.kt` |
| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/MeshStatusResponse.kt` |
| Create | `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/di/AppFunctionsModule.kt` |
| Modify | `androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt` |
| Create or Modify | `androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt` |
| Modify | `androidApp/src/google/AndroidManifest.xml` (point to GoogleMeshUtilApplication) |

View File

@@ -0,0 +1,313 @@
# Feature Specification: Android App Functions Integration
**Feature Branch**: `jamesarich/crispy-barnacle`
**Created**: 2026-05-21
**Status**: Draft
**Input**: User description: "Set up App Functions so our app can integrate better with system AI"
**Cross-Platform Spec**: KMP — interfaces defined in commonMain; Android implementation in androidApp (Google flavor first-class)
## Summary
Expose key Meshtastic capabilities as [Android App Functions](https://developer.android.com/ai/appfunctions) so system AI assistants (Gemini, etc.) can discover and invoke them on behalf of the user. App Functions act as on-device MCP tools, letting users interact with the mesh network through natural language — sending messages and checking mesh health — without manually navigating the app UI.
**Phase 1 (this spec)** focuses on a minimal MVP of 2 functions (`sendMessage` + `getMeshStatus`) to validate the integration end-to-end. Additional functions (listNodes, getRecentMessages, getNodePosition, waypoints, traceroute) will be added in Phase 2 after validation.
## Goals
1. Declare a minimal set of App Functions that validate the Meshtastic ↔ system AI integration pattern
2. Enable natural-language interactions like "Send a message to the mesh" or "How many nodes are online?"
3. Follow Android App Functions best practices: KDoc-described functions, `@AppFunctionSerializable` models, and proper `AppFunctionContext` usage
4. Define platform-agnostic interfaces in `commonMain` so other platforms (Desktop, iOS) can expose equivalent capabilities through their own AI systems in the future
5. Implement the Android-specific `@AppFunction` annotations in `androidApp` (Google flavor first-class)
6. Integrate with existing Koin DI to resolve repositories and managers
## Non-Goals
- Implementing a remote MCP server (App Functions are on-device only)
- Exposing radio configuration or admin operations to AI (security-sensitive; future consideration)
- Building custom AI/LLM features within the app itself
- Handling firmware updates or device provisioning through AI
- Exposing raw protobuf operations or low-level radio commands
- Phase 2+ functions (listNodes, getRecentMessages, getNodePosition, waypoints, traceroute) — deferred until Phase 1 validates the pattern
- F-Droid flavor implementation (platform API works there, but Google flavor is first-class target)
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Send a Mesh Message via AI (Priority: P1)
As a user talking to my phone's AI assistant, I want to say "Send a message to the mesh saying I'll be at the trailhead in 30 minutes" so I can communicate without opening the app.
**Why this priority**: Messaging is the #1 use case for Meshtastic; it's the most natural AI-triggered action.
**Independent Test**: Can be verified by invoking the App Function through the test agent app and confirming the message appears in the mesh message list.
**Acceptance Scenarios**:
1. **Given** the device is connected to a Meshtastic radio, **When** the AI assistant invokes `sendMessage` with text and a channel name, **Then** the channel is resolved via fuzzy matching, the message is transmitted over the mesh, and a confirmation with message ID is returned
2. **Given** the device is NOT connected to a radio, **When** the AI invokes `sendMessage`, **Then** the function returns an error result indicating no active connection
3. **Given** a valid node name is provided as the recipient, **When** the AI invokes `sendMessage` with a direct message target, **Then** the node name is fuzzy-matched and the message is sent as a DM to that specific node
4. **Given** the AI invokes `sendMessage` more than 5 times within 60 seconds, **When** the rate limit is exceeded, **Then** `AppFunctionLimitExceededException` is thrown with a descriptive message
5. **Given** a node name matches multiple nodes (e.g., "Jake" → "Jake's Radio" + "Jake_hiking"), **When** the match is ambiguous, **Then** `AppFunctionInvalidArgumentException` is thrown listing the candidate names for the AI to disambiguate
---
### User Story 2 - Query Mesh Network Status (Priority: P1)
As a user, I want to ask my AI assistant "How's my mesh network doing?" and get a summary of online nodes, my node's battery, and connection state.
**Why this priority**: Status queries are read-only and safe — ideal first-class AI capabilities.
**Independent Test**: Can be verified by invoking `getMeshStatus` and confirming the returned data matches the app's node list.
**Acceptance Scenarios**:
1. **Given** the device is connected, **When** the AI invokes `getMeshStatus`, **Then** it returns online node count, total node count, local battery level, and connection state
2. **Given** the device is disconnected, **When** the AI invokes `getMeshStatus`, **Then** it returns the disconnected state with last-known node counts
---
### Edge Cases
- What happens when multiple nodes match a name query? Return `AppFunctionInvalidArgumentException` listing candidate names so the AI agent can ask the user to clarify.
- What happens when multiple channels match a name query? Same approach — return candidates for disambiguation.
- What happens when the radio connection drops mid-operation? Return an error result; do not crash or hang.
- What happens with very long messages? Enforce the Meshtastic message length limit (237 bytes for standard, longer for PKC) and return `AppFunctionInvalidArgumentException` if exceeded.
- What happens if the rate limit is hit? Throw `AppFunctionLimitExceededException`; the AI agent handles this gracefully per platform conventions.
## Architecture
### Key Components
| Component | Module / File | Purpose |
|-----------|---------------|---------|
| AiFunctionProvider (interface) | `core/data/src/commonMain/.../ai/AiFunctionProvider.kt` | Platform-agnostic contract defining operations exposable to AI systems |
| MeshtasticAppFunctions | `androidApp/src/main/kotlin/.../appfunctions/MeshtasticAppFunctions.kt` | `@AppFunction`-annotated Android implementation |
| AppFunctionModels | `androidApp/src/main/kotlin/.../appfunctions/models/` | `@AppFunctionSerializable` data classes for function inputs/outputs |
| FuzzyNameResolver | `core/data/src/commonMain/.../ai/FuzzyNameResolver.kt` | Fuzzy matching for node and channel names (longest-substring, error if ambiguous) |
| RateLimiter | `core/data/src/commonMain/.../ai/RateLimiter.kt` | Sliding-window rate limiter (5 calls / 60s) for send operations |
| NodeRepository | `core/repository/` (commonMain) | Existing node data access — unchanged |
| PacketRepository | `core/repository/` (commonMain) | Existing message data access — unchanged |
| ServiceRepository | `core/repository/` (commonMain) | Existing connection state — unchanged |
| CommandSender | `core/repository/` (commonMain) | Existing mesh command dispatch — unchanged |
### Data Flow
```
System AI Agent (Gemini)
↓ (EXECUTE_APP_FUNCTIONS permission)
AppFunctionManager (Android OS, API 35+)
MeshtasticAppFunctions (@AppFunction annotated, androidApp)
AiFunctionProvider interface (commonMain contract)
FuzzyNameResolver → NodeRepository / RadioConfigRepository (name → ID resolution)
CommandSender / ServiceRepository (execute operation)
MeshServiceOrchestrator → Radio
```
### Dependency Graph
```mermaid
graph TD
A[System AI / Gemini] -->|AppFunctionManager| B[MeshtasticAppFunctions]
B --> C[AiFunctionProvider impl]
C --> D[FuzzyNameResolver]
C --> E[RateLimiter]
D --> F[NodeRepository]
D --> G[RadioConfigRepository]
C --> H[ServiceRepository]
C --> I[CommandSender]
H --> J[MeshServiceOrchestrator]
I --> J
J --> K[RadioInterfaceService]
```
### KMP Architecture Pattern
```
commonMain/
├── ai/
│ ├── AiFunctionProvider.kt # Interface: what operations AI can invoke
│ ├── AiFunctionResult.kt # Sealed result types (success/error)
│ ├── FuzzyNameResolver.kt # Name matching logic (testable, shared)
│ └── RateLimiter.kt # Token-bucket limiter (testable, shared)
androidApp/ (Google flavor)
├── appfunctions/
│ ├── MeshtasticAppFunctions.kt # @AppFunction declarations
│ ├── AppFunctionFactory.kt # Koin-based factory for DI
│ └── models/ # @AppFunctionSerializable types
```
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The app MUST declare App Functions using the `@AppFunction(isDescribedByKDoc = true)` annotation with comprehensive KDoc descriptions
- **FR-002**: All function parameters and return types MUST use `@AppFunctionSerializable` data classes with KDoc-described fields
- **FR-003**: `sendMessage` MUST resolve the destination via fuzzy name matching (channel name or node name), transmit the text message, and return a confirmation with message ID
- **FR-004**: `sendMessage` MUST enforce a rate limit of 5 invocations per 60-second sliding window, throwing `AppFunctionLimitExceededException` when exceeded
- **FR-005**: `getMeshStatus` MUST return current connection state, online/total node counts, and local device battery level
- **FR-006**: Fuzzy name matching MUST use longest-substring matching and throw `AppFunctionInvalidArgumentException` with candidate names when ambiguous
- **FR-007**: All functions MUST gracefully handle the disconnected state by throwing appropriate `AppFunctionException` subclasses (not generic exceptions)
- **FR-008**: The `sendMessage` function MUST validate message length against the Meshtastic protocol limit before transmission
- **FR-009**: A platform-agnostic `AiFunctionProvider` interface MUST be defined in `commonMain` with the operation contracts
- **FR-010**: The Android implementation MUST resolve dependencies through Koin via a custom `AppFunctionConfiguration.Provider`
- **FR-011**: `sendMessage` MUST send immediately without a confirmation dialog (AI invocation implies user intent per platform guidelines)
### Non-Functional Requirements
- **NFR-001**: App Functions MUST NOT expose sensitive configuration (admin channels, encryption keys, radio settings) to AI agents
- **NFR-002**: All App Function operations MUST complete within 5 seconds or throw a timeout error
- **NFR-003**: The App Functions layer requires `compileSdk` 36+ and MUST only be active on devices running Android 16 (API 35+)
- **NFR-004**: KDoc descriptions MUST be clear enough for an AI agent to understand the function's purpose without additional context
- **NFR-005**: The `AiFunctionProvider` interface in commonMain MUST have no Android dependencies
- **NFR-006**: Dependencies: `androidx.appfunctions:appfunctions:1.0.0-alpha09`, `appfunctions-service:1.0.0-alpha09`, `appfunctions-compiler:1.0.0-alpha09` (KSP)
## Source-Set Impact
| Source Set | Impact | Justification |
|-----------|--------|---------------|
| `commonMain` (core/data) | New: `AiFunctionProvider` interface, `FuzzyNameResolver`, `RateLimiter`, result types | Platform-agnostic contracts and shared logic |
| `androidMain` (androidApp, Google flavor) | New: `@AppFunction` declarations, serializable models, Koin factory | AppFunctions is an Android platform API |
| `jvmMain` | None | Desktop not affected (future: could implement via local MCP server) |
| `iosMain` | None | iOS not affected (future: could implement via App Intents) |
## Design Standards Compliance
- [x] New screens reviewed against design standards — N/A, no UI changes
- [x] M3 component selection verified — N/A, no UI components
- [x] Accessibility: N/A — App Functions are invoked by AI, not direct user interaction
- [x] Typography: N/A
- [x] KDoc documentation provides clear natural-language descriptions for AI discovery
## Privacy Assessment
- [x] No PII logged or exposed beyond what the user has already shared on the mesh
- [x] Position data gated behind existing privacy settings
- [x] Messages sent only with user's implicit consent (AI assistant invocation = user intent)
- [x] No encryption keys, admin channel configs, or radio settings exposed
- [x] Proto submodule (`core/proto`) not modified (read-only upstream)
- [x] `EXECUTE_APP_FUNCTIONS` permission required by callers — only authorized system agents can invoke
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Both declared App Functions are discoverable via `adb shell cmd app_function list-app-functions | grep org.meshtastic` on API 35+ devices
- **SC-002**: The test agent app can successfully invoke `sendMessage` and receive a confirmation with message ID when connected
- **SC-003**: The test agent app can invoke `getMeshStatus` and receive accurate node counts matching the app's UI within 1 second
- **SC-004**: `sendMessage` returns `AppFunctionLimitExceededException` after 5 rapid invocations within 60 seconds
- **SC-005**: Ambiguous name queries return `AppFunctionInvalidArgumentException` with candidate list
- **SC-006**: Zero crashes or ANRs introduced by App Function invocations
## Decisions Log
| # | Question | Decision | Rationale |
|---|----------|----------|-----------|
| 1 | User confirmation before sending? | No — send immediately | AI invocation implies user intent per platform guidelines; messaging is additive not destructive |
| 2 | Rate limiting? | Yes — 5 messages/60s sliding window | Aligns with radio duty-cycle constraints; platform provides `AppFunctionLimitExceededException` |
| 3 | Channel/node selection? | Fuzzy name matching | More natural for AI conversation; longest-substring match with error on ambiguity |
| 4 | Build flavor scope? | Google flavor first-class, KMP interfaces in commonMain | Platform API works everywhere but Gemini (primary caller) is Google; KMP future-proofs |
| 5 | Initial scope? | Minimal MVP (2 functions) | Validate integration pattern before expanding; sendMessage + getMeshStatus |
## Assumptions
- The user has already paired and connected to a Meshtastic radio device
- The app is installed on an Android 16+ (API 35) device that supports App Functions
- System AI agents have the `EXECUTE_APP_FUNCTIONS` permission (granted by the OS)
- The Jetpack AppFunctions library (`androidx.appfunctions:appfunctions-*` 1.0.0-alpha09) is stable enough for integration
- Koin dependency injection context is available when App Functions are invoked (app process is alive)
- The AppFunctions annotation processor (KSP) is compatible with the project's existing KSP setup
- `compileSdk` is already 37 (satisfies the ≥36 requirement for AppFunctions library)
## Open Questions
1. **Exact rate limit values**: Is 5 messages/60 seconds the right threshold, or should it align with a specific radio duty-cycle calculation?
2. **Background invocation**: Can App Functions be invoked when the app is in the background but the service is running? (Likely yes, since `AppFunctionService` runs in the app process)
## Future Considerations (Phase 3+)
- **getNodePosition**: "Where is Jake?" → return GPS coordinates (gated by privacy settings)
- **Waypoint management**: Create/delete waypoints via AI
- **Traceroute**: "Can I reach node X?" → invoke traceroute and return hop count
- **Location sharing**: "Share my location on the mesh" → trigger position broadcast
- **requestTraceroute**: Non-destructive route diagnostic via fuzzy node name
- **sendQuickChat**: Voice-triggered pre-configured message shortcuts
- **findNearbyNodes**: Location-aware proximity query sorted by distance
- **requestNodePosition**: Ask a specific node to share its GPS coordinates
- **Desktop/iOS parity**: Implement `AiFunctionProvider` via local MCP server (Desktop) or App Intents (iOS)
---
## Phase 3: Message History Functions
### User Story 6 - Read Recent Messages via AI (Priority: P1)
As a user returning from an activity, I want to ask "What messages did I miss?" and get a summary of recent mesh messages without opening the app.
**Why this priority**: "Catch me up" is the #1 voice query pattern for communication apps. Mesh messages arrive asynchronously during hikes/outdoor activities where the phone is pocketed.
**Independent Test**: Invoke `getRecentMessages` and confirm returned messages match the app's message list.
**Acceptance Scenarios**:
1. **Given** the device is connected and messages exist, **When** the AI invokes `getRecentMessages` without filters, **Then** the most recent messages (up to limit) are returned with sender name, text, channel, and timestamp
2. **Given** a contactName is provided, **When** the AI invokes `getRecentMessages` with that filter, **Then** only messages from that contact/channel are returned
3. **Given** no messages exist, **When** the AI invokes `getRecentMessages`, **Then** an empty list is returned (not an error)
4. **Given** the device is disconnected, **When** the AI invokes `getRecentMessages`, **Then** cached messages are still returned (message history is local)
---
### User Story 7 - Unread Message Summary via AI (Priority: P1)
As a user, I want to ask "Do I have unread messages?" and get a per-contact breakdown showing who messaged me and a preview of their last message.
**Why this priority**: Unread summaries let users decide whether to open the app, reducing unnecessary screen time during outdoor activities.
**Independent Test**: Invoke `getUnreadSummary` and confirm counts match the app's contact list badges.
**Acceptance Scenarios**:
1. **Given** unread messages exist from multiple contacts, **When** the AI invokes `getUnreadSummary`, **Then** the total unread count and per-contact breakdown (name, unread count, last message preview) are returned
2. **Given** no unread messages exist, **When** the AI invokes `getUnreadSummary`, **Then** totalUnreadCount is 0 and the contacts list is empty
3. **Given** a contact has been muted, **When** the AI invokes `getUnreadSummary`, **Then** muted contacts are excluded from the breakdown
---
### Functional Requirements (Phase 3)
- **FR-012**: `getRecentMessages` MUST return recent messages sorted newest-first, limited to a configurable count (default 20, max 50)
- **FR-013**: `getRecentMessages` MUST support optional `contactName` filter using the existing `FuzzyNameResolver`
- **FR-014**: `getRecentMessages` MUST NOT require an active radio connection (messages are cached locally)
- **FR-015**: `getUnreadSummary` MUST return total unread count and per-contact breakdown with last message preview
- **FR-016**: `getUnreadSummary` MUST exclude muted contacts from the breakdown
- **FR-017**: Both functions MUST respect the 5-second timeout constraint (NFR-002)
### Architecture Addition
```
commonMain/ai/
├── AiFunctionProvider.kt # + getRecentMessages(), getUnreadSummary()
├── AiFunctionResult.kt # + GetRecentMessagesResult, GetUnreadSummaryResult
androidApp/ (Google flavor)
├── appfunctions/
│ ├── MeshtasticAppFunctions.kt # + getRecentMessages, getUnreadSummary
│ └── AppFunctionModels.kt # + MessageInfo, UnreadSummaryResponse, ContactUnreadInfo
```
### Data Flow (Message History)
```
AiFunctionProvider.getRecentMessages()
PacketRepository.getMessagesFrom() / getContacts()
NodeRepository (resolve sender names)
Return MessageInfo list
```

View File

@@ -0,0 +1,349 @@
# Tasks: Android App Functions Integration
**Spec**: `specs/20260521-091500-app-functions/spec.md`
**Plan**: `specs/20260521-091500-app-functions/plan.md`
**Branch**: `jamesarich/crispy-barnacle`
## Task Dependency Graph
```mermaid
graph TD
T1[T1: Add dependencies] --> T2[T2: AiFunctionProvider interface]
T1 --> T3[T3: Result types]
T2 --> T4[T4: FuzzyNameResolver]
T3 --> T4
T2 --> T5[T5: RateLimiter]
T3 --> T5
T4 --> T6[T6: AiFunctionProviderImpl]
T5 --> T6
T6 --> T7[T7: Unit tests - commonMain]
T6 --> T8[T8: AppFunction models]
T8 --> T9[T9: MeshtasticAppFunctions class]
T9 --> T10[T10: DI & Application wiring]
T10 --> T11[T11: Build verification]
T7 --> T11
```
---
## T1: Add AppFunctions Dependencies & KSP Plugin
**Priority**: P0 (blocking)
**Depends on**: None
**Estimated effort**: Small
### Description
Add the `androidx.appfunctions` library suite to the version catalog and configure KSP in `androidApp`.
### Files to modify
- `gradle/libs.versions.toml` — add version + 3 library entries
- `androidApp/build.gradle.kts` — apply KSP plugin, add dependencies, add KSP arg
### Acceptance criteria
- [ ] `./gradlew :androidApp:dependencies | grep appfunctions` shows all 3 artifacts resolved
- [ ] `./gradlew :androidApp:compileGoogleDebugKotlin` compiles without errors
### Implementation notes
```toml
# In [versions]
appfunctions = "1.0.0-alpha09"
# In [libraries]
androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" }
androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" }
androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" }
```
In `androidApp/build.gradle.kts`:
- Add `alias(libs.plugins.ksp)` to plugins block (verify KSP plugin alias exists in catalog)
- Add `ksp { arg("appfunctions:aggregateAppFunctions", "true") }` block
- Add `implementation(libs.androidx.appfunctions)`
- Add `implementation(libs.androidx.appfunctions.service)`
- Add `ksp(libs.androidx.appfunctions.compiler)`
---
## T2: Create AiFunctionProvider Interface
**Priority**: P0 (blocking)
**Depends on**: T1
**Estimated effort**: Small
### Description
Define the platform-agnostic interface in `core/data` commonMain that declares what operations AI systems can invoke.
### Files to create
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt`
### Acceptance criteria
- [ ] Interface has `sendMessage` and `getMeshStatus` suspend functions
- [ ] No `android.*` or `java.*` imports
- [ ] `./gradlew :core:data:compileKotlinJvm` passes
### Implementation notes
```kotlin
package org.meshtastic.core.data.ai
interface AiFunctionProvider {
suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult
suspend fun getMeshStatus(): MeshStatusResult
}
```
---
## T3: Create Result Types (AiFunctionResult.kt)
**Priority**: P0 (blocking)
**Depends on**: T1
**Estimated effort**: Small
### Description
Define sealed result types for AI function operations. These are pure data classes with no platform dependencies.
### Files to create
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt`
### Acceptance criteria
- [ ] `SendMessageResult` sealed class with `Success`, `NotConnected`, `AmbiguousName`, `InvalidArgument`, `RateLimited` variants
- [ ] `MeshStatusResult` data class with `connectionState`, `onlineNodeCount`, `totalNodeCount`, `localBatteryLevel`, `localNodeName`
- [ ] No platform dependencies
- [ ] Compiles on all targets: `./gradlew :core:data:compileKotlinJvm`
---
## T4: Implement FuzzyNameResolver
**Priority**: P0 (blocking)
**Depends on**: T2, T3
**Estimated effort**: Medium
### Description
Implement longest-substring fuzzy name matching for resolving node names and channel names. Case-insensitive. Returns single match or error with candidates.
### Files to create
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt`
### Acceptance criteria
- [ ] Exact match (case-insensitive) returns immediately
- [ ] Unique fuzzy match (longest common substring ≥ 50% of query length) returns the match
- [ ] Multiple fuzzy matches returns `AmbiguousName` with candidate list
- [ ] No match returns empty/not-found
- [ ] `@Single` Koin annotation for DI registration
- [ ] Resolves node names from `NodeRepository.nodeDBbyNum`
- [ ] Resolves channel names from `RadioConfigRepository` channel list
- [ ] Admin channels excluded from resolution results (NFR-001: no sensitive config exposed)
### Implementation notes
- Constructor injects `NodeRepository` and `RadioConfigRepository`
- `resolveNodeName(query: String): NodeNameResult` → sealed: `Found(nodeNum, userId)`, `Ambiguous(candidates)`, `NotFound`
- `resolveChannelName(query: String): ChannelNameResult` → sealed: `Found(channelIndex, name)`, `Ambiguous(candidates)`, `NotFound`
- Longest Common Substring algorithm for fuzzy scoring
---
## T5: Implement RateLimiter
**Priority**: P0 (blocking)
**Depends on**: T2, T3
**Estimated effort**: Small
### Description
Sliding-window rate limiter: tracks last 5 invocation timestamps within a 60-second window. Thread-safe via Mutex.
### Files to create
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/RateLimiter.kt`
### Acceptance criteria
- [ ] Permits up to 5 calls within 60 seconds
- [ ] Returns `RateLimited(retryAfterSeconds)` when all 5 slots are within the window
- [ ] Thread-safe (Mutex)
- [ ] Uses injected `Clock` for testability
- [ ] `@Single` Koin annotation
### Implementation notes
```kotlin
@Single
class RateLimiter(private val clock: Clock) {
private val mutex = Mutex()
private val maxCalls = 5
private val windowDuration = 60.seconds
private val timestamps = ArrayDeque<Instant>(maxCalls)
suspend fun tryAcquire(): RateLimitResult { ... }
}
sealed class RateLimitResult {
data object Permitted : RateLimitResult()
data class Limited(val retryAfterSeconds: Int) : RateLimitResult()
}
```
---
## T6: Implement AiFunctionProviderImpl
**Priority**: P0 (blocking)
**Depends on**: T4, T5
**Estimated effort**: Medium
### Description
Wire the AI function interface to existing repositories. This is the core business logic bridge.
### Files to create
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt`
### Acceptance criteria
- [ ] `@Single` Koin annotation, binds `AiFunctionProvider` interface
- [ ] `sendMessage` flow: check connection → rate limit → resolve name → validate length → create DataPacket → send → return Success
- [ ] `getMeshStatus` flow: read connectionState, node counts, battery, node name
- [ ] Disconnected state returns `NotConnected` (not exception)
- [ ] Message length validated against 237-byte limit
- [ ] All operations complete within timeout (use `withTimeout(5.seconds)`)
### Implementation notes
- Inject: `NodeRepository`, `ServiceRepository`, `CommandSender`, `RadioConfigRepository`, `FuzzyNameResolver`, `RateLimiter`
- For `sendMessage`: construct `DataPacket(to = resolvedNodeId, bytes = text.encodeToByteString(), dataType = Portnums.TEXT_MESSAGE_APP, channel = resolvedChannelIndex)`
- For `getMeshStatus`: use `.value` on StateFlows (no suspension needed for connection state), `.first()` for counts
- `ConnectionState.CONNECTED` check before proceeding
---
## T7: Unit Tests for commonMain AI Layer
**Priority**: P1
**Depends on**: T6
**Estimated effort**: Medium
### Description
Comprehensive unit tests for FuzzyNameResolver, RateLimiter, and AiFunctionProviderImpl.
### Files to create
- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt`
- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/RateLimiterTest.kt`
- `core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt`
### Acceptance criteria
- [ ] **FuzzyNameResolverTest**: exact match, unique fuzzy, ambiguous, no match, case insensitivity, channel name resolution, channel ambiguity
- [ ] **FuzzyNameResolverTest (security)**: admin channels excluded from resolution results (NFR-001)
- [ ] **RateLimiterTest**: permits under limit, blocks at limit, refills after window expires (use fake Clock)
- [ ] **AiFunctionProviderImplTest**: happy path send, disconnected error, rate limited, ambiguous name, message too long, getMeshStatus connected, getMeshStatus disconnected
- [ ] **AiFunctionProviderImplTest (timeout)**: verify operations throw timeout after 5 seconds when repository hangs (NFR-002)
- [ ] All tests pass: `./gradlew :core:data:allTests`
### Implementation notes
- Use `runTest(UnconfinedTestDispatcher())` for coroutine tests
- Mock repositories with fakes or mockk
- Inject fake `Clock` that can be advanced for rate limiter tests
---
## T8: Create AppFunction Serializable Models
**Priority**: P1
**Depends on**: T6
**Estimated effort**: Small
### Description
Define `@AppFunctionSerializable` response types for the Android platform layer.
### Files to create
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/SendMessageResponse.kt`
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/models/MeshStatusResponse.kt`
### Acceptance criteria
- [ ] Both classes annotated with `@AppFunctionSerializable(isDescribedByKDoc = true)`
- [ ] All fields have KDoc descriptions clear enough for AI agent understanding
- [ ] `SendMessageResponse`: messageId (Int), channelName (String), timestamp (Long)
- [ ] `MeshStatusResponse`: connectionState (String), onlineNodeCount (Int), totalNodeCount (Int), batteryLevel (Int?), localNodeName (String?)
- [ ] Compiles: `./gradlew :androidApp:compileGoogleDebugKotlin`
---
## T9: Implement MeshtasticAppFunctions Class
**Priority**: P1
**Depends on**: T8
**Estimated effort**: Medium
### Description
Create the `@AppFunction`-annotated class that the Android system discovers and invokes. Maps commonMain results to platform exceptions.
### Files to create
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/MeshtasticAppFunctions.kt`
### Acceptance criteria
- [ ] `sendMessage` annotated with `@AppFunction(isDescribedByKDoc = true)` with comprehensive KDoc
- [ ] `getMeshStatus` annotated with `@AppFunction(isDescribedByKDoc = true)` with comprehensive KDoc
- [ ] First param is always `AppFunctionContext`
- [ ] Error mapping: `NotConnected``AppFunctionAppException`, `AmbiguousName``AppFunctionInvalidArgumentException`, `RateLimited``AppFunctionLimitExceededException`, `InvalidArgument``AppFunctionInvalidArgumentException`
- [ ] Constructor takes `AiFunctionProvider`
- [ ] Compiles with KSP generating schema
---
## T10: DI Wiring & Application Configuration
**Priority**: P1
**Depends on**: T9
**Estimated effort**: Medium
### Description
Wire AppFunctions into Koin DI and configure the Application class to provide the factory.
### Files to create
- `androidApp/src/google/kotlin/org/meshtastic/app/appfunctions/di/AppFunctionsModule.kt`
- `androidApp/src/google/kotlin/org/meshtastic/app/GoogleMeshUtilApplication.kt`
### Files to modify
- `androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt` — add `AppFunctionsModule` to includes
- `androidApp/src/google/AndroidManifest.xml` — point `android:name` to `GoogleMeshUtilApplication`
### Acceptance criteria
- [ ] `AppFunctionsModule` provides `MeshtasticAppFunctions` via Koin
- [ ] `FlavorModule` includes `AppFunctionsModule`
- [ ] `GoogleMeshUtilApplication` extends `MeshUtilApplication` and implements `AppFunctionConfiguration.Provider`
- [ ] Google flavor manifest uses `GoogleMeshUtilApplication`
- [ ] F-Droid flavor unaffected (still uses base `MeshUtilApplication`)
- [ ] App launches without crash: `./gradlew :androidApp:assembleGoogleDebug`
### Implementation notes
- `GoogleMeshUtilApplication` overrides `appFunctionConfiguration`:
```kotlin
override val appFunctionConfiguration: AppFunctionConfiguration
get() = AppFunctionConfiguration.Builder()
.addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
get<MeshtasticAppFunctions>()
}
.build()
```
- Check if google flavor already has a custom Application subclass
---
## T11: Build Verification & Final Checks
**Priority**: P1
**Depends on**: T7, T10
**Estimated effort**: Small
### Description
Run full verification suite and confirm AppFunctions are properly registered.
### Commands to run
```bash
./gradlew spotlessApply
./gradlew spotlessCheck detekt
./gradlew assembleDebug
./gradlew test allTests
./gradlew :androidApp:assembleGoogleDebug
./gradlew :androidApp:assembleFdroidDebug
```
### Acceptance criteria
- [ ] All formatting passes (`spotlessCheck`)
- [ ] All static analysis passes (`detekt`)
- [ ] Both flavors compile (`assembleGoogleDebug`, `assembleFdroidDebug`)
- [ ] All tests pass (`test allTests`)
- [ ] No new warnings introduced
- [ ] KSP generates AppFunction schema XML in build output