mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 14:50:26 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
97
specs/20260521-091500-app-functions/checklist.md
Normal file
97
specs/20260521-091500-app-functions/checklist.md
Normal 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
|
||||
243
specs/20260521-091500-app-functions/plan.md
Normal file
243
specs/20260521-091500-app-functions/plan.md
Normal 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) |
|
||||
242
specs/20260521-091500-app-functions/spec.md
Normal file
242
specs/20260521-091500-app-functions/spec.md
Normal 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)
|
||||
349
specs/20260521-091500-app-functions/tasks.md
Normal file
349
specs/20260521-091500-app-functions/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user