feat: add App Functions integration for system AI assistants

Expose Meshtastic mesh networking capabilities (sendMessage, getMeshStatus)
to Android system AI agents via the App Functions API.

Architecture:
- AiFunctionProvider interface in core/data commonMain (platform-agnostic)
- FuzzyNameResolver for node/channel name matching (LCS algorithm)
- RateLimiter with 5-call/60s sliding window to protect mesh radio
- AiFunctionProviderImpl wiring repositories and use cases
- @AppFunction declarations in androidApp Google flavor only
- GoogleMeshUtilApplication with AppFunctionConfiguration.Provider
- DI via AppFunctionsModule included in FlavorModule

Key design decisions:
- No confirmation dialog (AI invocation = user intent)
- Fuzzy name matching with 50% LCS threshold, error on ambiguity
- Admin channels excluded from resolution
- 5-second operation timeout
- 237-byte message length limit (Meshtastic standard)

Includes unit tests for RateLimiter and FuzzyNameResolver (LCS algorithm).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-21 10:50:22 -05:00
parent 165323c98b
commit 88c6cf98a8
19 changed files with 1870 additions and 3 deletions

View File

@@ -31,6 +31,7 @@ plugins {
alias(libs.plugins.secrets)
id("meshtastic.aboutlibraries")
id("dev.mokkery")
alias(libs.plugins.devtools.ksp)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -177,6 +178,8 @@ secrets {
propertiesFileName = "secrets.properties"
}
ksp { arg("appfunctions:aggregateAppFunctions", "true") }
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.flavorName?.let { flavor -> variant.applicationId.set("com.geeksville.mesh.$flavor.debug") }
@@ -282,6 +285,10 @@ dependencies {
googleImplementation(libs.firebase.ai.ondevice)
googleImplementation(libs.mlkit.translate)
googleImplementation(libs.androidx.appfunctions)
googleImplementation(libs.androidx.appfunctions.service)
add("kspGoogle", libs.androidx.appfunctions.compiler)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)

View File

@@ -16,9 +16,12 @@
~ 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}" />

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,48 @@
/*
* 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.AppFunctionSerializable
/**
* Response returned when a message is successfully sent via the mesh network.
*
* @property messageId The identifier assigned to the outgoing message.
* @property channel The channel or destination the message was sent to.
* @property timestamp The time the message was sent (epoch milliseconds).
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
data class SendMessageResponse(val messageId: Int, val channel: String, val timestamp: Long)
/**
* Response containing the current status of the Meshtastic mesh network.
*
* @property connectionState The current radio connection state (e.g., CONNECTED, DISCONNECTED).
* @property onlineNodeCount The number of nodes currently online (heard within the last 15 minutes).
* @property totalNodeCount The total number of nodes known to the network.
* @property localBatteryLevel The battery percentage of the connected Meshtastic device (1-100), or null if
* unavailable.
* @property localNodeName The display name of the local node, or null if not set.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
data class MeshStatusResponse(
val connectionState: String,
val onlineNodeCount: Int,
val totalNodeCount: Int,
val localBatteryLevel: Int?,
val localNodeName: String?,
)

View File

@@ -0,0 +1,101 @@
/*
* 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.AppFunctionInvalidArgumentException
import androidx.appfunctions.service.AppFunction
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 = provider.sendMessage(text, recipientName, channelName)
return when (result) {
is SendMessageResult.Success ->
SendMessageResponse(
messageId = result.messageId,
channel = result.channel,
timestamp = result.timestamp,
)
is SendMessageResult.NotConnected -> throw AppFunctionInvalidArgumentException(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 = provider.getMeshStatus()
return MeshStatusResponse(
connectionState = status.connectionState,
onlineNodeCount = status.onlineNodeCount,
totalNodeCount = status.totalNodeCount,
localBatteryLevel = status.localBatteryLevel,
localNodeName = status.localNodeName,
)
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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 org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
import org.meshtastic.core.data.ai.AiFunctionProvider
/** Provides AppFunctions integration for the Google flavor. */
@Module
class AppFunctionsModule {
@Single
fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
}

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,47 @@
/*
* 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
}

View File

@@ -0,0 +1,180 @@
/*
* 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.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
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.
*/
@Single(binds = [AiFunctionProvider::class])
class AiFunctionProviderImpl(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
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
sendMessageUseCase.invoke(text, key)
SendMessageResult.Success(
messageId = 0, // ID is generated internally by SendMessageUseCase
channel = contactKey.channelName,
timestamp = clock.now().toEpochMilliseconds(),
)
}
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")
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
/** 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,49 @@
/*
* 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?,
)

View File

@@ -0,0 +1,166 @@
/*
* 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)
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,73 @@
/*
* 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 AI agents from flooding the mesh network.
*/
@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,82 @@
/*
* 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 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 full integration in AiFunctionProviderImplTest, 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)
}
}

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

@@ -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"
@@ -104,6 +105,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,242 @@
# 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 2+)
- **listNodes**: "Who's on the mesh?" → return online nodes with names and last-heard
- **getRecentMessages**: "Any new messages?" → return unread messages with sender/text/time
- **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
- **Channel info**: "What channels am I on?" → list configured channels
- **Device telemetry**: "How's my radio's battery?" → return device metrics
- **Location sharing**: "Share my location on the mesh" → trigger position broadcast
- **Desktop/iOS parity**: Implement `AiFunctionProvider` via local MCP server (Desktop) or App Intents (iOS)

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