mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-13 16:35:47 -04:00
feat(ai): Add App Functions for system AI integration (#5585)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
16
.skills/compose-ui/strings-index.txt
generated
16
.skills/compose-ui/strings-index.txt
generated
@@ -68,6 +68,22 @@ analytics_notice
|
||||
analytics_okay
|
||||
analytics_platforms
|
||||
any
|
||||
### APP ###
|
||||
app_functions_get_channel_info
|
||||
app_functions_get_device_status
|
||||
app_functions_get_mesh_metrics
|
||||
app_functions_get_mesh_status
|
||||
app_functions_get_node_details
|
||||
app_functions_get_node_list
|
||||
app_functions_get_recent_messages
|
||||
app_functions_get_unread_summary
|
||||
app_functions_master_summary
|
||||
app_functions_master_toggle
|
||||
app_functions_read_section
|
||||
app_functions_send_message
|
||||
app_functions_settings
|
||||
app_functions_settings_summary
|
||||
app_functions_write_section
|
||||
app_notifications
|
||||
app_settings
|
||||
app_too_old
|
||||
|
||||
@@ -32,6 +32,7 @@ plugins {
|
||||
alias(libs.plugins.secrets)
|
||||
id("meshtastic.aboutlibraries")
|
||||
id("dev.mokkery")
|
||||
alias(libs.plugins.devtools.ksp)
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
@@ -178,6 +179,8 @@ secrets {
|
||||
propertiesFileName = "secrets.properties"
|
||||
}
|
||||
|
||||
ksp { arg("appfunctions:aggregateAppFunctions", "true") }
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
variant.flavorName?.let { flavor -> variant.applicationId.set("com.geeksville.mesh.$flavor.debug") }
|
||||
@@ -283,6 +286,10 @@ dependencies {
|
||||
googleImplementation(libs.firebase.ai.ondevice)
|
||||
googleImplementation(libs.mlkit.translate)
|
||||
|
||||
googleImplementation(libs.androidx.appfunctions)
|
||||
googleImplementation(libs.androidx.appfunctions.service)
|
||||
add("kspGoogle", libs.androidx.appfunctions.compiler)
|
||||
|
||||
fdroidImplementation(libs.osmdroid.android)
|
||||
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
|
||||
fdroidImplementation(libs.osmbonuspack)
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Module(includes = [FDroidNetworkModule::class, FdroidAiModule::class])
|
||||
class FlavorModule
|
||||
class FlavorModule {
|
||||
@Single
|
||||
@Named("googleServicesAvailable")
|
||||
fun googleServicesAvailable(): Boolean = false
|
||||
}
|
||||
|
||||
@@ -16,12 +16,18 @@
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<application
|
||||
android:name="org.meshtastic.app.GoogleMeshUtilApplication"
|
||||
tools:replace="android:name">
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${MAPS_API_KEY}" />
|
||||
<property
|
||||
android:name="android.app.appfunctions.app_metadata"
|
||||
android:resource="@xml/app_metadata" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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,212 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.ai.appfunctions
|
||||
|
||||
import androidx.appfunctions.AppFunctionIntValueConstraint
|
||||
import androidx.appfunctions.AppFunctionSerializable
|
||||
import androidx.appfunctions.AppFunctionStringValueConstraint
|
||||
|
||||
/** Response returned when a message is successfully sent via the mesh network. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class SendMessageResponse(
|
||||
/** The identifier assigned to the outgoing message. */
|
||||
val messageId: Int,
|
||||
/** The channel or destination the message was sent to. */
|
||||
val channel: String,
|
||||
/** The time the message was sent (epoch milliseconds). */
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
/** Response containing the current status of the Meshtastic mesh network. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class MeshStatusResponse(
|
||||
/** The current radio connection state (e.g., CONNECTED, DISCONNECTED). */
|
||||
@property:AppFunctionStringValueConstraint(enumValues = ["CONNECTED", "DISCONNECTED", "DEVICE_SLEEP"])
|
||||
val connectionState: String,
|
||||
/** The number of nodes currently online (heard within the last 2 hours). */
|
||||
val onlineNodeCount: Int,
|
||||
/** The total number of nodes known to the network. */
|
||||
val totalNodeCount: Int,
|
||||
/** The battery percentage of the connected Meshtastic device (1-100), or null if unavailable. */
|
||||
val localBatteryLevel: Int?,
|
||||
/** The display name of the local node, or null if not set. */
|
||||
val localNodeName: String?,
|
||||
)
|
||||
|
||||
/** Information about a single mesh node. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class NodeInfo(
|
||||
/** The unique node identifier in Meshtastic hex format (e.g., !abc12345). */
|
||||
val id: String,
|
||||
/** The human-readable name of the node. */
|
||||
val name: String,
|
||||
/** The node's battery percentage (0-100), or null if unavailable. */
|
||||
val batteryLevel: Int?,
|
||||
/** The time this node was last heard from (epoch milliseconds). */
|
||||
val lastHeard: Long,
|
||||
/** Whether this node is currently considered online. */
|
||||
val isOnline: Boolean,
|
||||
)
|
||||
|
||||
/** Response containing a list of nodes visible on the mesh network. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetNodeListResponse(
|
||||
/** List of nodes sorted by most recently heard first. */
|
||||
val nodes: List<NodeInfo>,
|
||||
)
|
||||
|
||||
/** Information about a single mesh channel. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class ChannelInfo(
|
||||
/** The channel index (0-7). */
|
||||
@property:AppFunctionIntValueConstraint(enumValues = [0, 1, 2, 3, 4, 5, 6, 7]) val index: Int,
|
||||
/** The human-readable name of the channel. */
|
||||
val name: String,
|
||||
/** Whether this is the primary/default channel. */
|
||||
val isPrimary: Boolean,
|
||||
/** Whether uplink is enabled for this channel. */
|
||||
val uplinkEnabled: Boolean,
|
||||
/** Whether downlink is enabled for this channel. */
|
||||
val downlinkEnabled: Boolean,
|
||||
)
|
||||
|
||||
/** Response containing the list of available mesh channels. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetChannelInfoResponse(
|
||||
/** List of all configured channels. */
|
||||
val channels: List<ChannelInfo>,
|
||||
)
|
||||
|
||||
/** Response containing the status of the local Meshtastic device. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetDeviceStatusResponse(
|
||||
/** The hardware model of the device (e.g., "Meshtastic nRF52840"). */
|
||||
val model: String,
|
||||
/** The firmware version string. */
|
||||
val firmwareVersion: String,
|
||||
/** The device battery percentage (0-100), or null if not battery-powered. */
|
||||
val batteryLevel: Int?,
|
||||
/** The charging state (CHARGING, NOT_CHARGING, or UNKNOWN). */
|
||||
@property:AppFunctionStringValueConstraint(enumValues = ["CHARGING", "NOT_CHARGING", "UNKNOWN"])
|
||||
val chargingStatus: String,
|
||||
/** The display name of the device, or null if not set. */
|
||||
val deviceName: String?,
|
||||
/** Whether the radio is currently active and connected. */
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
||||
/** Response containing detailed telemetry for a specific mesh node. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetNodeDetailsResponse(
|
||||
/** Node ID in hex format (e.g., "!abc12345"). */
|
||||
val id: String,
|
||||
/** User ID string for this node. */
|
||||
val userId: String,
|
||||
/** Display name of the node. */
|
||||
val name: String,
|
||||
/** Battery percentage (0-100), or null if unavailable. */
|
||||
val batteryLevel: Int?,
|
||||
/** Supply voltage in volts, or null if unavailable. */
|
||||
val voltage: Float?,
|
||||
/** Hardware model string. */
|
||||
val hardwareModel: String,
|
||||
/** Firmware version string. */
|
||||
val firmwareVersion: String,
|
||||
/** Signal-to-noise ratio of strongest signal. */
|
||||
val snr: Float,
|
||||
/** Received signal strength indicator in dB. */
|
||||
val rssi: Int,
|
||||
/** Number of hops away from local node (-1 if unknown). */
|
||||
val hopsAway: Int,
|
||||
/** Channel index this node is on. */
|
||||
val channel: Int,
|
||||
/** Last heard timestamp (milliseconds since epoch). */
|
||||
val lastHeard: Long,
|
||||
/** User role or device type. */
|
||||
val userRole: String,
|
||||
/** Whether the user is licensed. */
|
||||
val isLicensed: Boolean,
|
||||
/** Latitude in degrees, or null if unknown. */
|
||||
val latitude: Double?,
|
||||
/** Longitude in degrees, or null if unknown. */
|
||||
val longitude: Double?,
|
||||
)
|
||||
|
||||
/** Response containing aggregate mesh network metrics. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetMeshMetricsResponse(
|
||||
/** Total number of known nodes. */
|
||||
val totalNodeCount: Int,
|
||||
/** Number of nodes currently online. */
|
||||
val onlineNodeCount: Int,
|
||||
/** Average battery level across mesh, or null if no data. */
|
||||
val averageBatteryLevel: Int?,
|
||||
/** Estimated health score (0-100). */
|
||||
val meshHealthScore: Int,
|
||||
/** Timestamp of most recent packet (ms since epoch). */
|
||||
val mostRecentPacketTime: Long,
|
||||
/** Mesh uptime in seconds. */
|
||||
val meshUptimeSeconds: Long,
|
||||
/** Channel utilization percentage, or null if unavailable. */
|
||||
val channelUtilizationPercent: Int?,
|
||||
)
|
||||
|
||||
/** Response containing recent messages from the mesh network. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetRecentMessagesResponse(
|
||||
/** List of recent messages ordered by most recent first. */
|
||||
val messages: List<MessageInfo>,
|
||||
)
|
||||
|
||||
/** Information about a single mesh message. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class MessageInfo(
|
||||
/** Display name of the message sender. */
|
||||
val senderName: String,
|
||||
/** The message text content. */
|
||||
val text: String,
|
||||
/** Name of the channel or contact the message belongs to. */
|
||||
val contactName: String,
|
||||
/** Timestamp when the message was received (ms since epoch). */
|
||||
val receivedTime: Long,
|
||||
/** True if this message was sent by the local user. */
|
||||
val fromLocal: Boolean,
|
||||
/** True if this message has been read by the user. */
|
||||
val read: Boolean,
|
||||
)
|
||||
|
||||
/** Response containing a summary of unread messages across all contacts. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class GetUnreadSummaryResponse(
|
||||
/** Total number of unread messages across all non-muted contacts. */
|
||||
val totalUnreadCount: Int,
|
||||
/** Per-contact breakdown of unread messages, sorted by most recent. */
|
||||
val contacts: List<ContactUnreadInfo>,
|
||||
)
|
||||
|
||||
/** Unread message details for a single contact or channel. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
data class ContactUnreadInfo(
|
||||
/** Display name of the contact or channel. */
|
||||
val name: String,
|
||||
/** Number of unread messages from this contact. */
|
||||
val unreadCount: Int,
|
||||
/** Preview text of the most recent message (up to 100 chars), or null if unavailable. */
|
||||
val lastMessagePreview: String?,
|
||||
/** Timestamp of the most recent message (ms since epoch), or null if unavailable. */
|
||||
val lastMessageTime: Long?,
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.ai.appfunctions
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appfunctions.AppFunctionManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.AppFunctionsPrefs
|
||||
|
||||
/**
|
||||
* Observes [AppFunctionsPrefs] and synchronizes the enabled/disabled state of each AppFunction with the system via
|
||||
* [AppFunctionManager].
|
||||
*
|
||||
* When the master toggle is off, all functions are disabled regardless of individual toggles.
|
||||
*/
|
||||
class AppFunctionStateSync(
|
||||
private val context: Context,
|
||||
private val prefs: AppFunctionsPrefs,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
init {
|
||||
observeAndSync()
|
||||
}
|
||||
|
||||
private fun observeAndSync() {
|
||||
data class FunctionToggle(val id: String, val enabled: StateFlow<Boolean>)
|
||||
|
||||
val functions =
|
||||
listOf(
|
||||
FunctionToggle(SEND_MESSAGE_ID, prefs.sendMessageEnabled),
|
||||
FunctionToggle(GET_MESH_STATUS_ID, prefs.getMeshStatusEnabled),
|
||||
FunctionToggle(GET_NODE_LIST_ID, prefs.getNodeListEnabled),
|
||||
FunctionToggle(GET_CHANNEL_INFO_ID, prefs.getChannelInfoEnabled),
|
||||
FunctionToggle(GET_DEVICE_STATUS_ID, prefs.getDeviceStatusEnabled),
|
||||
FunctionToggle(GET_NODE_DETAILS_ID, prefs.getNodeDetailsEnabled),
|
||||
FunctionToggle(GET_MESH_METRICS_ID, prefs.getMeshMetricsEnabled),
|
||||
FunctionToggle(GET_RECENT_MESSAGES_ID, prefs.getRecentMessagesEnabled),
|
||||
FunctionToggle(GET_UNREAD_SUMMARY_ID, prefs.getUnreadSummaryEnabled),
|
||||
)
|
||||
|
||||
// Combine master toggle with each individual toggle
|
||||
combine(prefs.masterEnabled, combine(functions.map { it.enabled }) { it.toList() }) { master, toggles ->
|
||||
functions.mapIndexed { index, fn -> fn.id to (master && toggles[index]) }
|
||||
}
|
||||
.onEach { states -> syncStates(states) }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
private suspend fun syncStates(states: List<Pair<String, Boolean>>) {
|
||||
val manager = AppFunctionManager.getInstance(context) ?: return
|
||||
|
||||
for ((functionId, enabled) in states) {
|
||||
val state =
|
||||
if (enabled) {
|
||||
AppFunctionManager.APP_FUNCTION_STATE_ENABLED
|
||||
} else {
|
||||
AppFunctionManager.APP_FUNCTION_STATE_DISABLED
|
||||
}
|
||||
try {
|
||||
manager.setAppFunctionEnabled(functionId, state)
|
||||
} catch (_: Exception) {
|
||||
// Function may not be indexed yet (first launch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CLASS_PREFIX = "org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions#"
|
||||
|
||||
const val SEND_MESSAGE_ID = "${CLASS_PREFIX}sendMessage"
|
||||
const val GET_MESH_STATUS_ID = "${CLASS_PREFIX}getMeshStatus"
|
||||
const val GET_NODE_LIST_ID = "${CLASS_PREFIX}getNodeList"
|
||||
const val GET_CHANNEL_INFO_ID = "${CLASS_PREFIX}getChannelInfo"
|
||||
const val GET_DEVICE_STATUS_ID = "${CLASS_PREFIX}getDeviceStatus"
|
||||
const val GET_NODE_DETAILS_ID = "${CLASS_PREFIX}getNodeDetails"
|
||||
const val GET_MESH_METRICS_ID = "${CLASS_PREFIX}getMeshMetrics"
|
||||
const val GET_RECENT_MESSAGES_ID = "${CLASS_PREFIX}getRecentMessages"
|
||||
const val GET_UNREAD_SUMMARY_ID = "${CLASS_PREFIX}getUnreadSummary"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.ai.appfunctions
|
||||
|
||||
import androidx.appfunctions.AppFunctionContext
|
||||
import androidx.appfunctions.AppFunctionElementNotFoundException
|
||||
import androidx.appfunctions.AppFunctionIntValueConstraint
|
||||
import androidx.appfunctions.AppFunctionInvalidArgumentException
|
||||
import androidx.appfunctions.AppFunctionNotSupportedException
|
||||
import androidx.appfunctions.service.AppFunction
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import org.meshtastic.core.data.ai.AiFunctionProvider
|
||||
import org.meshtastic.core.data.ai.SendMessageResult
|
||||
|
||||
/**
|
||||
* Exposes Meshtastic mesh networking capabilities to system AI assistants via the Android App Functions API. Functions
|
||||
* declared here are discoverable by the system and can be invoked by AI agents such as Gemini.
|
||||
*/
|
||||
class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
|
||||
/**
|
||||
* Send a text message over the Meshtastic mesh radio network.
|
||||
*
|
||||
* Messages are transmitted to nearby mesh nodes using LoRa radio. The mesh network is ideal for off-grid
|
||||
* communications where cellular service is unavailable.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param text The message text to send (max 237 bytes).
|
||||
* @param recipientName Optional name of a specific node to send a direct message to. If omitted, the message is
|
||||
* broadcast to all nodes on the specified channel.
|
||||
* @param channelName Optional channel name to broadcast on. If omitted, uses the primary channel. Ignored when
|
||||
* recipientName is specified.
|
||||
* @return A [SendMessageResponse] with the message ID, channel, and timestamp.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun sendMessage(
|
||||
context: AppFunctionContext,
|
||||
text: String,
|
||||
recipientName: String? = null,
|
||||
channelName: String? = null,
|
||||
): SendMessageResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.sendMessage(text, recipientName, channelName)
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the mesh is connected and try again.",
|
||||
)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is SendMessageResult.Success ->
|
||||
SendMessageResponse(
|
||||
messageId = result.messageId,
|
||||
channel = result.channel,
|
||||
timestamp = result.timestamp,
|
||||
)
|
||||
|
||||
is SendMessageResult.NotConnected -> throw AppFunctionNotSupportedException(result.message)
|
||||
|
||||
is SendMessageResult.AmbiguousName -> {
|
||||
val names = result.candidates.joinToString()
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Multiple nodes match that name: $names. Please be more specific.",
|
||||
)
|
||||
}
|
||||
|
||||
is SendMessageResult.InvalidArgument -> throw AppFunctionInvalidArgumentException(result.reason)
|
||||
|
||||
is SendMessageResult.RateLimited ->
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Rate limit exceeded. Try again in ${result.retryAfterSeconds} seconds.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of the Meshtastic mesh network.
|
||||
*
|
||||
* Returns connection state, number of online nodes, total known nodes, the connected device's battery level, and
|
||||
* the local node name.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A [MeshStatusResponse] with the current mesh network status.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getMeshStatus(context: AppFunctionContext): MeshStatusResponse {
|
||||
val status =
|
||||
try {
|
||||
provider.getMeshStatus()
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the mesh is connected and try again.",
|
||||
)
|
||||
}
|
||||
|
||||
return MeshStatusResponse(
|
||||
connectionState = status.connectionState,
|
||||
onlineNodeCount = status.onlineNodeCount,
|
||||
totalNodeCount = status.totalNodeCount,
|
||||
localBatteryLevel = status.localBatteryLevel,
|
||||
localNodeName = status.localNodeName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all nodes currently visible on the Meshtastic mesh network.
|
||||
*
|
||||
* Returns detailed information about each node including name, battery level, and last heard time. Nodes are sorted
|
||||
* by most recently heard first.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A list of nodes with their current status and metrics.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getNodeList(context: AppFunctionContext): GetNodeListResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getNodeList()
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the mesh is connected and try again.",
|
||||
)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetNodeListResult.Success ->
|
||||
GetNodeListResponse(
|
||||
nodes =
|
||||
result.nodes.map {
|
||||
NodeInfo(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
batteryLevel = it.batteryLevel,
|
||||
lastHeard = it.lastHeard,
|
||||
isOnline = it.isOnline,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetNodeListResult.NotConnected ->
|
||||
throw AppFunctionNotSupportedException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetNodeListResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available Meshtastic mesh channels and their configurations.
|
||||
*
|
||||
* Returns details about each channel including name, index, primary status, and uplink/downlink settings.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A list of channels with their current configuration.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getChannelInfo(context: AppFunctionContext): GetChannelInfoResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getChannelInfo()
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the mesh is connected and try again.",
|
||||
)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetChannelInfoResult.Success ->
|
||||
GetChannelInfoResponse(
|
||||
channels =
|
||||
result.channels.map {
|
||||
ChannelInfo(
|
||||
index = it.index,
|
||||
name = it.name,
|
||||
isPrimary = it.isPrimary,
|
||||
uplinkEnabled = it.uplinkEnabled,
|
||||
downlinkEnabled = it.downlinkEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetChannelInfoResult.NotConnected ->
|
||||
throw AppFunctionNotSupportedException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetChannelInfoResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status and metrics of the local Meshtastic radio device.
|
||||
*
|
||||
* Returns hardware model, firmware version, battery level, charging status, and current radio state.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return Device status with current metrics and configuration.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getDeviceStatus(context: AppFunctionContext): GetDeviceStatusResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getDeviceStatus()
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the device is initialized and try again.",
|
||||
)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetDeviceStatusResult.Success ->
|
||||
GetDeviceStatusResponse(
|
||||
model = result.device.model,
|
||||
firmwareVersion = result.device.firmwareVersion,
|
||||
batteryLevel = result.device.batteryLevel,
|
||||
chargingStatus = result.device.chargingStatus,
|
||||
deviceName = result.device.deviceName,
|
||||
isActive = result.device.isActive,
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetDeviceStatusResult.NotAvailable ->
|
||||
throw AppFunctionNotSupportedException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetDeviceStatusResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve detailed telemetry and status for a specific mesh node.
|
||||
*
|
||||
* Returns per-node metrics including battery level, signal strength, hardware model, and location data.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param nodeId The target node ID (e.g., '!abc12345' or user ID).
|
||||
* @return A [GetNodeDetailsResponse] with detailed node information.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getNodeDetails(context: AppFunctionContext, nodeId: String): GetNodeDetailsResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getNodeDetails(nodeId)
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the mesh is connected and try again.",
|
||||
)
|
||||
}
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetNodeDetailsResult.Success ->
|
||||
GetNodeDetailsResponse(
|
||||
id = result.node.id,
|
||||
userId = result.node.userId,
|
||||
name = result.node.name,
|
||||
batteryLevel = result.node.batteryLevel,
|
||||
voltage = result.node.voltage,
|
||||
hardwareModel = result.node.hardwareModel,
|
||||
firmwareVersion = result.node.firmwareVersion,
|
||||
snr = result.node.snr,
|
||||
rssi = result.node.rssi,
|
||||
hopsAway = result.node.hopsAway,
|
||||
channel = result.node.channel,
|
||||
lastHeard = result.node.lastHeard,
|
||||
userRole = result.node.userRole,
|
||||
isLicensed = result.node.isLicensed,
|
||||
latitude = result.node.latitude,
|
||||
longitude = result.node.longitude,
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetNodeDetailsResult.NotConnected ->
|
||||
throw AppFunctionNotSupportedException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetNodeDetailsResult.NotFound ->
|
||||
throw AppFunctionElementNotFoundException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetNodeDetailsResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve aggregate network metrics and statistics for the entire mesh.
|
||||
*
|
||||
* Returns mesh-wide analytics including total node count, online nodes, average battery level, and health score.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A [GetMeshMetricsResponse] with mesh-wide statistics.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getMeshMetrics(context: AppFunctionContext): GetMeshMetricsResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getMeshMetrics()
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException(
|
||||
"Request timed out. Ensure the mesh is connected and try again.",
|
||||
)
|
||||
}
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetMeshMetricsResult.Success ->
|
||||
GetMeshMetricsResponse(
|
||||
totalNodeCount = result.metrics.totalNodeCount,
|
||||
onlineNodeCount = result.metrics.onlineNodeCount,
|
||||
averageBatteryLevel = result.metrics.averageBatteryLevel,
|
||||
meshHealthScore = result.metrics.meshHealthScore,
|
||||
mostRecentPacketTime = result.metrics.mostRecentPacketTime,
|
||||
meshUptimeSeconds = result.metrics.meshUptimeSeconds,
|
||||
channelUtilizationPercent = result.metrics.channelUtilizationPercent,
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetMeshMetricsResult.NotConnected ->
|
||||
throw AppFunctionNotSupportedException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetMeshMetricsResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recent messages received over the Meshtastic mesh radio network.
|
||||
*
|
||||
* Returns a list of recent messages from the local message history. Messages are stored locally and do not require
|
||||
* an active mesh connection. Useful for catching up on conversations or reviewing recent communications.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from
|
||||
* all contacts sorted by most recent.
|
||||
* @param limit Maximum number of messages to return (1–50). Defaults to 20.
|
||||
* @return A [GetRecentMessagesResponse] containing the list of recent messages.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getRecentMessages(
|
||||
context: AppFunctionContext,
|
||||
contactName: String? = null,
|
||||
@AppFunctionIntValueConstraint(enumValues = [1, 5, 10, 20, 50])
|
||||
limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT,
|
||||
): GetRecentMessagesResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getRecentMessages(contactName, limit)
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException("Request timed out. Try again or reduce the message limit.")
|
||||
}
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetRecentMessagesResult.Success ->
|
||||
GetRecentMessagesResponse(
|
||||
messages =
|
||||
result.messages.map { msg ->
|
||||
MessageInfo(
|
||||
senderName = msg.senderName,
|
||||
text = msg.text,
|
||||
contactName = msg.contactName,
|
||||
receivedTime = msg.receivedTime,
|
||||
fromLocal = msg.fromLocal,
|
||||
read = msg.read,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetRecentMessagesResult.ContactNotFound ->
|
||||
throw AppFunctionElementNotFoundException(result.message)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetRecentMessagesResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of unread messages across all Meshtastic mesh contacts.
|
||||
*
|
||||
* Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread,
|
||||
* and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getUnreadSummary(context: AppFunctionContext): GetUnreadSummaryResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getUnreadSummary()
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw AppFunctionInvalidArgumentException("Request timed out. Try again.")
|
||||
}
|
||||
return when (result) {
|
||||
is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Success ->
|
||||
GetUnreadSummaryResponse(
|
||||
totalUnreadCount = result.summary.totalUnreadCount,
|
||||
contacts =
|
||||
result.summary.contacts.map { contact ->
|
||||
ContactUnreadInfo(
|
||||
name = contact.name,
|
||||
unreadCount = contact.unreadCount,
|
||||
lastMessagePreview = contact.lastMessagePreview,
|
||||
lastMessageTime = contact.lastMessageTime,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Error ->
|
||||
throw AppFunctionInvalidArgumentException(result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import android.content.Context
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.app.ai.appfunctions.AppFunctionStateSync
|
||||
import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
|
||||
import org.meshtastic.core.data.ai.AiFunctionProvider
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.AppFunctionsPrefs
|
||||
|
||||
/** Provides AppFunctions integration for the Google flavor. */
|
||||
@Module
|
||||
class AppFunctionsModule {
|
||||
@Single
|
||||
fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
|
||||
|
||||
@Single(createdAtStart = true)
|
||||
fun appFunctionStateSync(
|
||||
context: Context,
|
||||
prefs: AppFunctionsPrefs,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
): AppFunctionStateSync = AppFunctionStateSync(context, prefs, dispatchers)
|
||||
|
||||
@Single
|
||||
@Named("googleServicesAvailable")
|
||||
fun googleServicesAvailable(): Boolean = true
|
||||
}
|
||||
@@ -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
|
||||
|
||||
26
androidApp/src/google/res/xml/app_metadata.xml
Normal file
26
androidApp/src/google/res/xml/app_metadata.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Meshtastic LLC
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
-->
|
||||
|
||||
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
|
||||
appfn:description="Meshtastic is a mesh radio networking app for off-grid communications using LoRa devices.
|
||||
|
||||
Operational Patterns:
|
||||
- Use 'getMeshStatus' to verify the radio is connected before calling functions that require a live connection.
|
||||
- Use 'getNodeList' or 'getNodeDetails' to resolve node names and IDs before calling 'sendMessage' with a recipientName.
|
||||
- Use 'getChannelInfo' to discover available channel names before sending broadcast messages to non-primary channels.
|
||||
- 'getRecentMessages' and 'getUnreadSummary' work offline from local message history and do not require an active connection.
|
||||
- Prefer 'getUnreadSummary' for a quick overview, then 'getRecentMessages' with a contactName filter for details.
|
||||
|
||||
Constraints:
|
||||
- Message text is limited to 237 bytes.
|
||||
- Node and channel name resolution is fuzzy (partial matches accepted).
|
||||
- Rate limit: 10 messages per 60 seconds via 'sendMessage'.
|
||||
- Message history limit: 1-50 messages per query (default 20)."
|
||||
appfn:displayDescription="@string/app_description" />
|
||||
@@ -17,4 +17,5 @@
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Meshtastic</string>
|
||||
<string name="app_description">Off-grid mesh networking for secure, long-range communications via LoRa radio.</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.ai.appfunctions
|
||||
|
||||
import androidx.appfunctions.AppFunctionContext
|
||||
import androidx.appfunctions.AppFunctionElementNotFoundException
|
||||
import androidx.appfunctions.AppFunctionInvalidArgumentException
|
||||
import androidx.appfunctions.AppFunctionNotSupportedException
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.data.ai.AiFunctionProvider
|
||||
import org.meshtastic.core.data.ai.ChannelSummary
|
||||
import org.meshtastic.core.data.ai.ContactUnread
|
||||
import org.meshtastic.core.data.ai.DeviceStatus
|
||||
import org.meshtastic.core.data.ai.GetChannelInfoResult
|
||||
import org.meshtastic.core.data.ai.GetDeviceStatusResult
|
||||
import org.meshtastic.core.data.ai.GetMeshMetricsResult
|
||||
import org.meshtastic.core.data.ai.GetNodeDetailsResult
|
||||
import org.meshtastic.core.data.ai.GetNodeListResult
|
||||
import org.meshtastic.core.data.ai.GetRecentMessagesResult
|
||||
import org.meshtastic.core.data.ai.GetUnreadSummaryResult
|
||||
import org.meshtastic.core.data.ai.MeshMetrics
|
||||
import org.meshtastic.core.data.ai.MeshStatusResult
|
||||
import org.meshtastic.core.data.ai.MessageSummary
|
||||
import org.meshtastic.core.data.ai.NodeDetails
|
||||
import org.meshtastic.core.data.ai.NodeSummary
|
||||
import org.meshtastic.core.data.ai.SendMessageResult
|
||||
import org.meshtastic.core.data.ai.UnreadSummary
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35], application = android.app.Application::class)
|
||||
class MeshtasticAppFunctionsTest {
|
||||
|
||||
private val provider: AiFunctionProvider = mock(MockMode.autofill)
|
||||
private val context: AppFunctionContext = mock(MockMode.autofill)
|
||||
private val appFunctions = MeshtasticAppFunctions(provider)
|
||||
|
||||
@Test
|
||||
fun sendMessage_success() = runTest {
|
||||
everySuspend { provider.sendMessage("Hello", "Alice", null) } returns
|
||||
SendMessageResult.Success(messageId = 1234, channel = "Primary", timestamp = 1700000000L)
|
||||
|
||||
val response = appFunctions.sendMessage(context, "Hello", "Alice", null)
|
||||
|
||||
assertEquals(1234, response.messageId)
|
||||
assertEquals("Primary", response.channel)
|
||||
assertEquals(1700000000L, response.timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_ambiguousName() = runTest {
|
||||
everySuspend { provider.sendMessage("Hello", "Al", null) } returns
|
||||
SendMessageResult.AmbiguousName(listOf("Alice", "Albert"))
|
||||
|
||||
val exception =
|
||||
assertFailsWith<AppFunctionInvalidArgumentException> {
|
||||
appFunctions.sendMessage(context, "Hello", "Al", null)
|
||||
}
|
||||
assertTrue(exception.message!!.contains("Multiple nodes match that name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_notConnected() = runTest {
|
||||
everySuspend { provider.sendMessage("Hello", "Alice", null) } returns
|
||||
SendMessageResult.NotConnected("Not connected")
|
||||
|
||||
assertFailsWith<AppFunctionNotSupportedException> { appFunctions.sendMessage(context, "Hello", "Alice", null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMeshStatus_success() = runTest {
|
||||
everySuspend { provider.getMeshStatus() } returns
|
||||
MeshStatusResult(
|
||||
connectionState = "CONNECTED",
|
||||
onlineNodeCount = 5,
|
||||
totalNodeCount = 10,
|
||||
localBatteryLevel = 88,
|
||||
localNodeName = "MyNode",
|
||||
)
|
||||
|
||||
val response = appFunctions.getMeshStatus(context)
|
||||
|
||||
assertEquals("CONNECTED", response.connectionState)
|
||||
assertEquals(5, response.onlineNodeCount)
|
||||
assertEquals(10, response.totalNodeCount)
|
||||
assertEquals(88, response.localBatteryLevel)
|
||||
assertEquals("MyNode", response.localNodeName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeList_success() = runTest {
|
||||
val nodes =
|
||||
listOf(
|
||||
NodeSummary(id = "1", name = "Alice", batteryLevel = 90, lastHeard = 1700000000L, isOnline = true),
|
||||
NodeSummary(id = "2", name = "Bob", batteryLevel = null, lastHeard = 1600000000L, isOnline = false),
|
||||
)
|
||||
everySuspend { provider.getNodeList() } returns GetNodeListResult.Success(nodes)
|
||||
|
||||
val response = appFunctions.getNodeList(context)
|
||||
|
||||
assertEquals(2, response.nodes.size)
|
||||
assertEquals("1", response.nodes[0].id)
|
||||
assertEquals("Alice", response.nodes[0].name)
|
||||
assertEquals(90, response.nodes[0].batteryLevel)
|
||||
assertTrue(response.nodes[0].isOnline)
|
||||
assertEquals("Bob", response.nodes[1].name)
|
||||
assertEquals(null, response.nodes[1].batteryLevel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getChannelInfo_success() = runTest {
|
||||
val channels =
|
||||
listOf(
|
||||
ChannelSummary(
|
||||
index = 0,
|
||||
name = "Primary",
|
||||
isPrimary = true,
|
||||
uplinkEnabled = true,
|
||||
downlinkEnabled = true,
|
||||
),
|
||||
ChannelSummary(
|
||||
index = 1,
|
||||
name = "Secondary",
|
||||
isPrimary = false,
|
||||
uplinkEnabled = true,
|
||||
downlinkEnabled = false,
|
||||
),
|
||||
)
|
||||
everySuspend { provider.getChannelInfo() } returns GetChannelInfoResult.Success(channels)
|
||||
|
||||
val response = appFunctions.getChannelInfo(context)
|
||||
|
||||
assertEquals(2, response.channels.size)
|
||||
assertEquals("Primary", response.channels[0].name)
|
||||
assertTrue(response.channels[0].isPrimary)
|
||||
assertTrue(response.channels[0].uplinkEnabled)
|
||||
assertEquals("Secondary", response.channels[1].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getDeviceStatus_success() = runTest {
|
||||
val device =
|
||||
DeviceStatus(
|
||||
model = "T-Beam",
|
||||
firmwareVersion = "2.3.15",
|
||||
batteryLevel = 100,
|
||||
chargingStatus = "NOT_CHARGING",
|
||||
deviceName = "MyDevice",
|
||||
isActive = true,
|
||||
)
|
||||
everySuspend { provider.getDeviceStatus() } returns GetDeviceStatusResult.Success(device)
|
||||
|
||||
val response = appFunctions.getDeviceStatus(context)
|
||||
|
||||
assertEquals("T-Beam", response.model)
|
||||
assertEquals("2.3.15", response.firmwareVersion)
|
||||
assertEquals(100, response.batteryLevel)
|
||||
assertEquals("NOT_CHARGING", response.chargingStatus)
|
||||
assertEquals("MyDevice", response.deviceName)
|
||||
assertTrue(response.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_success() = runTest {
|
||||
val nodeDetails =
|
||||
NodeDetails(
|
||||
id = "!abc12345",
|
||||
userId = "abc12345",
|
||||
name = "TestNode",
|
||||
batteryLevel = 75,
|
||||
voltage = 3.9f,
|
||||
hardwareModel = "T-Echo",
|
||||
firmwareVersion = "2.3.15",
|
||||
snr = 5.5f,
|
||||
rssi = -90,
|
||||
hopsAway = 1,
|
||||
channel = 0,
|
||||
lastHeard = 1700000000L,
|
||||
userRole = "CLIENT",
|
||||
isLicensed = false,
|
||||
latitude = 45.0,
|
||||
longitude = -90.0,
|
||||
)
|
||||
everySuspend { provider.getNodeDetails("!abc12345") } returns GetNodeDetailsResult.Success(nodeDetails)
|
||||
|
||||
val response = appFunctions.getNodeDetails(context, "!abc12345")
|
||||
|
||||
assertEquals("!abc12345", response.id)
|
||||
assertEquals("TestNode", response.name)
|
||||
assertEquals(75, response.batteryLevel)
|
||||
assertEquals(3.9f, response.voltage)
|
||||
assertEquals(45.0, response.latitude)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_notFound() = runTest {
|
||||
everySuspend { provider.getNodeDetails("!unknown") } returns GetNodeDetailsResult.NotFound("Node not found")
|
||||
|
||||
assertFailsWith<AppFunctionElementNotFoundException> { appFunctions.getNodeDetails(context, "!unknown") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMeshMetrics_success() = runTest {
|
||||
val metrics =
|
||||
MeshMetrics(
|
||||
totalNodeCount = 12,
|
||||
onlineNodeCount = 4,
|
||||
averageBatteryLevel = 82,
|
||||
meshHealthScore = 95,
|
||||
mostRecentPacketTime = 1700000000L,
|
||||
meshUptimeSeconds = 3600L,
|
||||
channelUtilizationPercent = 5,
|
||||
)
|
||||
everySuspend { provider.getMeshMetrics() } returns GetMeshMetricsResult.Success(metrics)
|
||||
|
||||
val response = appFunctions.getMeshMetrics(context)
|
||||
|
||||
assertEquals(12, response.totalNodeCount)
|
||||
assertEquals(4, response.onlineNodeCount)
|
||||
assertEquals(82, response.averageBatteryLevel)
|
||||
assertEquals(95, response.meshHealthScore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecentMessages_success() = runTest {
|
||||
val messages =
|
||||
listOf(
|
||||
MessageSummary(
|
||||
senderName = "Alice",
|
||||
text = "Hi",
|
||||
contactName = "Alice",
|
||||
receivedTime = 1700000000L,
|
||||
fromLocal = false,
|
||||
read = true,
|
||||
),
|
||||
)
|
||||
everySuspend { provider.getRecentMessages(null, 10) } returns GetRecentMessagesResult.Success(messages)
|
||||
|
||||
val response = appFunctions.getRecentMessages(context, null, 10)
|
||||
|
||||
assertEquals(1, response.messages.size)
|
||||
assertEquals("Alice", response.messages[0].senderName)
|
||||
assertEquals("Hi", response.messages[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getUnreadSummary_success() = runTest {
|
||||
val summary =
|
||||
UnreadSummary(
|
||||
totalUnreadCount = 3,
|
||||
contacts =
|
||||
listOf(
|
||||
ContactUnread(
|
||||
name = "Alice",
|
||||
unreadCount = 2,
|
||||
lastMessagePreview = "Hi",
|
||||
lastMessageTime = 1700000000L,
|
||||
),
|
||||
),
|
||||
)
|
||||
everySuspend { provider.getUnreadSummary() } returns GetUnreadSummaryResult.Success(summary)
|
||||
|
||||
val response = appFunctions.getUnreadSummary(context)
|
||||
|
||||
assertEquals(3, response.totalUnreadCount)
|
||||
assertEquals(1, response.contacts.size)
|
||||
assertEquals("Alice", response.contacts[0].name)
|
||||
assertEquals(2, response.contacts[0].unreadCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
/**
|
||||
* Platform-agnostic contract defining operations that AI systems can invoke.
|
||||
*
|
||||
* This interface abstracts the app capabilities exposed to system AI assistants. On Android, the implementation is
|
||||
* wired to AppFunctions. On other platforms, equivalent mechanisms (App Intents on iOS, MCP on Desktop) can implement
|
||||
* this.
|
||||
*/
|
||||
interface AiFunctionProvider {
|
||||
|
||||
/**
|
||||
* Send a text message over the mesh network.
|
||||
*
|
||||
* The destination is resolved by name using fuzzy matching — either a node name for direct messages or a channel
|
||||
* name for broadcast. If both are null, the message is broadcast on the primary channel.
|
||||
*
|
||||
* @param text The message text to send.
|
||||
* @param recipientName Optional node name for direct messages.
|
||||
* @param channelName Optional channel name. Defaults to primary channel if omitted.
|
||||
* @return Result indicating success or a typed failure reason.
|
||||
*/
|
||||
suspend fun sendMessage(text: String, recipientName: String? = null, channelName: String? = null): SendMessageResult
|
||||
|
||||
/**
|
||||
* Get the current mesh network status summary.
|
||||
*
|
||||
* @return Current connection state, node counts, and local device info.
|
||||
*/
|
||||
suspend fun getMeshStatus(): MeshStatusResult
|
||||
|
||||
/**
|
||||
* List all nodes currently visible on the mesh network.
|
||||
*
|
||||
* @return Success with list of nodes, or failure if not connected.
|
||||
*/
|
||||
suspend fun getNodeList(): GetNodeListResult
|
||||
|
||||
/**
|
||||
* List all available mesh channels and their configurations.
|
||||
*
|
||||
* @return Success with list of channels, or failure if not connected.
|
||||
*/
|
||||
suspend fun getChannelInfo(): GetChannelInfoResult
|
||||
|
||||
/**
|
||||
* Get status and metrics of the local mesh radio device.
|
||||
*
|
||||
* @return Success with device status, or failure if device unavailable.
|
||||
*/
|
||||
suspend fun getDeviceStatus(): GetDeviceStatusResult
|
||||
|
||||
/**
|
||||
* Get detailed telemetry and status for a specific mesh node.
|
||||
*
|
||||
* @param nodeId The target node ID (in Meshtastic format: "!hex" or user ID).
|
||||
* @return Success with node details, or failure if not connected or node not found.
|
||||
*/
|
||||
suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResult
|
||||
|
||||
/**
|
||||
* Get aggregate network metrics and statistics for the entire mesh.
|
||||
*
|
||||
* @return Success with mesh metrics, or failure if not connected.
|
||||
*/
|
||||
suspend fun getMeshMetrics(): GetMeshMetricsResult
|
||||
|
||||
/**
|
||||
* Get recent messages from the mesh network.
|
||||
*
|
||||
* Messages are returned from the local cache — an active radio connection is not required.
|
||||
*
|
||||
* @param contactName Optional contact/channel name to filter by. Uses fuzzy matching.
|
||||
* @param limit Maximum number of messages to return (default 20, max 50).
|
||||
* @return Success with list of messages, or failure if contact not found.
|
||||
*/
|
||||
suspend fun getRecentMessages(
|
||||
contactName: String? = null,
|
||||
limit: Int = DEFAULT_MESSAGE_LIMIT,
|
||||
): GetRecentMessagesResult
|
||||
|
||||
/**
|
||||
* Get a summary of unread messages grouped by contact.
|
||||
*
|
||||
* Returns the total unread count and a per-contact breakdown with the last message preview. Muted contacts are
|
||||
* excluded.
|
||||
*
|
||||
* @return Unread summary with per-contact breakdown.
|
||||
*/
|
||||
suspend fun getUnreadSummary(): GetUnreadSummaryResult
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MESSAGE_LIMIT = 20
|
||||
const val MAX_MESSAGE_LIMIT = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Implementation of [AiFunctionProvider] that bridges AI function invocations to existing Meshtastic repositories and
|
||||
* use cases.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@Single(binds = [AiFunctionProvider::class])
|
||||
class AiFunctionProviderImpl(
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val sendMessageUseCase: SendMessageUseCase,
|
||||
private val fuzzyNameResolver: FuzzyNameResolver,
|
||||
private val rateLimiter: RateLimiter,
|
||||
private val clock: Clock,
|
||||
) : AiFunctionProvider {
|
||||
|
||||
override suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResult =
|
||||
withTimeout(OPERATION_TIMEOUT) {
|
||||
// Check connection
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
return@withTimeout SendMessageResult.NotConnected(
|
||||
"Not connected to a Meshtastic radio. Please connect first.",
|
||||
)
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
when (val rateResult = rateLimiter.tryAcquire()) {
|
||||
is RateLimitResult.Permitted -> {
|
||||
/* proceed */
|
||||
}
|
||||
|
||||
is RateLimitResult.Limited -> {
|
||||
return@withTimeout SendMessageResult.RateLimited(rateResult.retryAfterSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate message length
|
||||
val messageBytes = text.encodeToByteArray()
|
||||
if (messageBytes.size > MAX_MESSAGE_LENGTH) {
|
||||
return@withTimeout SendMessageResult.InvalidArgument(
|
||||
"Message too long: ${messageBytes.size} bytes exceeds maximum of $MAX_MESSAGE_LENGTH bytes.",
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve destination
|
||||
val contactKey =
|
||||
resolveContactKey(recipientName, channelName)
|
||||
?: return@withTimeout SendMessageResult.InvalidArgument("Could not resolve destination.")
|
||||
|
||||
// Handle ambiguous results from resolution
|
||||
if (contactKey is ResolvedContact.Ambiguous) {
|
||||
return@withTimeout SendMessageResult.AmbiguousName(contactKey.candidates)
|
||||
}
|
||||
|
||||
val key = (contactKey as ResolvedContact.Resolved).contactKey
|
||||
|
||||
// Send via existing use case and capture the generated messageId
|
||||
try {
|
||||
val messageId = sendMessageUseCase.invoke(text, key)
|
||||
|
||||
SendMessageResult.Success(
|
||||
messageId = messageId,
|
||||
channel = contactKey.channelName,
|
||||
timestamp = clock.now().toEpochMilliseconds(),
|
||||
)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
SendMessageResult.InvalidArgument("Failed to send message: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMeshStatus(): MeshStatusResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
val connectionState = serviceRepository.connectionState.value
|
||||
val onlineCount = nodeRepository.onlineNodeCount.first()
|
||||
val totalCount = nodeRepository.totalNodeCount.first()
|
||||
val ourNode = nodeRepository.ourNodeInfo.value
|
||||
val batteryLevel = ourNode?.batteryLevel?.takeIf { it in 1..MAX_BATTERY_LEVEL }
|
||||
val nodeName = ourNode?.user?.long_name?.takeIf { it.isNotBlank() }
|
||||
|
||||
MeshStatusResult(
|
||||
connectionState = connectionState.name,
|
||||
onlineNodeCount = onlineCount,
|
||||
totalNodeCount = totalCount,
|
||||
localBatteryLevel = batteryLevel,
|
||||
localNodeName = nodeName,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override suspend fun getNodeList(): GetNodeListResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
return@withTimeout GetNodeListResult.NotConnected("Not connected to a Meshtastic radio.")
|
||||
}
|
||||
|
||||
try {
|
||||
val nodeMap = nodeRepository.nodeDBbyNum.first()
|
||||
val nodes =
|
||||
nodeMap.values.map { node ->
|
||||
NodeSummary(
|
||||
id = "!${node.num.toString(HEX_RADIX)}",
|
||||
name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
|
||||
batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
|
||||
lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
|
||||
isOnline = node.isOnline,
|
||||
)
|
||||
}
|
||||
GetNodeListResult.Success(nodes.sortedByDescending { it.lastHeard })
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetNodeListResult.Error("Failed to retrieve node list: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override suspend fun getChannelInfo(): GetChannelInfoResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
return@withTimeout GetChannelInfoResult.NotConnected("Not connected to a Meshtastic radio.")
|
||||
}
|
||||
|
||||
try {
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
val channels =
|
||||
channelSet.settings.mapIndexed { index, channel ->
|
||||
ChannelSummary(
|
||||
index = index,
|
||||
name = channel.name.takeIf { it.isNotBlank() } ?: "Channel $index",
|
||||
isPrimary = index == 0,
|
||||
uplinkEnabled = channel.uplink_enabled,
|
||||
downlinkEnabled = channel.downlink_enabled,
|
||||
)
|
||||
}
|
||||
GetChannelInfoResult.Success(channels)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetChannelInfoResult.Error("Failed to retrieve channel info: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override suspend fun getDeviceStatus(): GetDeviceStatusResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
try {
|
||||
val ourNode =
|
||||
nodeRepository.ourNodeInfo.value
|
||||
?: return@withTimeout GetDeviceStatusResult.NotAvailable("Device not yet initialized.")
|
||||
|
||||
val deviceStatus =
|
||||
DeviceStatus(
|
||||
model = ourNode.metadata?.hw_model?.name ?: "Unknown",
|
||||
firmwareVersion = ourNode.metadata?.firmware_version ?: "Unknown",
|
||||
batteryLevel = ourNode.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
|
||||
chargingStatus = "UNKNOWN",
|
||||
deviceName = ourNode.user.long_name.takeIf { it.isNotBlank() },
|
||||
isActive = serviceRepository.connectionState.value == ConnectionState.Connected,
|
||||
)
|
||||
GetDeviceStatusResult.Success(deviceStatus)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetDeviceStatusResult.Error("Failed to retrieve device status: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
return@withTimeout GetNodeDetailsResult.NotConnected("Not connected to a Meshtastic radio.")
|
||||
}
|
||||
|
||||
try {
|
||||
val node =
|
||||
if (nodeId.startsWith("!")) {
|
||||
// Hex format: extract number and search
|
||||
val nodeNum = nodeId.drop(1).toInt(HEX_RADIX)
|
||||
nodeRepository.nodeDBbyNum.first()[nodeNum]
|
||||
} else {
|
||||
// User ID format
|
||||
nodeRepository.getNode(nodeId)
|
||||
}
|
||||
|
||||
if (node == null) {
|
||||
return@withTimeout GetNodeDetailsResult.NotFound("Node not found: $nodeId")
|
||||
}
|
||||
|
||||
// Check if position is valid (both coords zero AND time zero indicates no position fix)
|
||||
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0 || node.position.time > 0
|
||||
|
||||
val details =
|
||||
NodeDetails(
|
||||
id = "!${node.num.toString(HEX_RADIX)}",
|
||||
userId = node.user.id,
|
||||
name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
|
||||
batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
|
||||
voltage = node.deviceMetrics.voltage,
|
||||
hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
|
||||
firmwareVersion = node.metadata?.firmware_version ?: "Unknown",
|
||||
snr = node.snr,
|
||||
rssi = node.rssi,
|
||||
hopsAway = node.hopsAway,
|
||||
channel = node.channel,
|
||||
lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
|
||||
userRole = node.user.role.name,
|
||||
isLicensed = node.user.is_licensed,
|
||||
latitude = node.latitude.takeIf { hasValidPosition },
|
||||
longitude = node.longitude.takeIf { hasValidPosition },
|
||||
)
|
||||
GetNodeDetailsResult.Success(details)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetNodeDetailsResult.Error("Failed to retrieve node details: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override suspend fun getMeshMetrics(): GetMeshMetricsResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
return@withTimeout GetMeshMetricsResult.NotConnected("Not connected to a Meshtastic radio.")
|
||||
}
|
||||
|
||||
try {
|
||||
val totalCount = nodeRepository.totalNodeCount.first()
|
||||
val onlineCount = nodeRepository.onlineNodeCount.first()
|
||||
|
||||
// Calculate average battery level
|
||||
val nodeMap = nodeRepository.nodeDBbyNum.first()
|
||||
val batteryLevels = nodeMap.values.mapNotNull { it.deviceMetrics.battery_level }
|
||||
val avgBattery =
|
||||
if (batteryLevels.isNotEmpty()) {
|
||||
(batteryLevels.sum() / batteryLevels.size).coerceIn(0, MAX_BATTERY_LEVEL)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Mesh health score: 0-100 based on online ratio and recent activity
|
||||
val healthScore =
|
||||
when {
|
||||
totalCount == 0 -> 0
|
||||
onlineCount == 0 -> HEALTH_SCORE_DEGRADED
|
||||
else -> (HEALTH_SCORE_BASE + (HEALTH_SCORE_ONLINE_RATIO * onlineCount) / totalCount).toInt()
|
||||
}
|
||||
|
||||
// Find most recent packet: max lastHeard across all nodes (convert seconds to ms)
|
||||
val mostRecentPacketTimeMs =
|
||||
nodeMap.values.maxOfOrNull { it.lastHeard }?.takeIf { it > 0 }?.toLong()?.times(MS_PER_SEC)
|
||||
?: clock.now().toEpochMilliseconds()
|
||||
|
||||
// Get local device uptime from its DeviceMetrics (node #0 is typically the local device)
|
||||
val localNode = nodeMap.values.find { it.num == 0 } ?: nodeMap.values.firstOrNull()
|
||||
val meshUptimeSeconds = localNode?.deviceMetrics?.uptime_seconds?.toLong() ?: 0L
|
||||
|
||||
val metrics =
|
||||
MeshMetrics(
|
||||
totalNodeCount = totalCount,
|
||||
onlineNodeCount = onlineCount,
|
||||
averageBatteryLevel = avgBattery,
|
||||
meshHealthScore = healthScore.coerceIn(0, HEALTH_SCORE_MAX),
|
||||
mostRecentPacketTime = mostRecentPacketTimeMs,
|
||||
meshUptimeSeconds = meshUptimeSeconds,
|
||||
channelUtilizationPercent = null, // Could compute from radioConfigRepository if needed
|
||||
)
|
||||
GetMeshMetricsResult.Success(metrics)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetMeshMetricsResult.Error("Failed to retrieve mesh metrics: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override suspend fun getRecentMessages(contactName: String?, limit: Int): GetRecentMessagesResult =
|
||||
withTimeout(OPERATION_TIMEOUT) {
|
||||
try {
|
||||
val effectiveLimit = limit.coerceIn(1, AiFunctionProvider.MAX_MESSAGE_LIMIT)
|
||||
|
||||
// Resolve contact key if a name filter is provided
|
||||
val contactKey =
|
||||
if (contactName != null) {
|
||||
resolveContactKeyForRead(contactName)
|
||||
?: return@withTimeout GetRecentMessagesResult.ContactNotFound(
|
||||
"Contact not found: $contactName",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val messages =
|
||||
if (contactKey != null) {
|
||||
// Fetch messages from a specific contact
|
||||
packetRepository
|
||||
.getMessagesFrom(
|
||||
contact = contactKey,
|
||||
limit = effectiveLimit,
|
||||
includeFiltered = false,
|
||||
getNode = { userId -> nodeRepository.getNode(userId ?: "") },
|
||||
)
|
||||
.first()
|
||||
} else {
|
||||
// Fetch recent messages across all contacts
|
||||
val contacts = packetRepository.getContacts().first()
|
||||
contacts.keys
|
||||
.flatMap { key ->
|
||||
packetRepository
|
||||
.getMessagesFrom(
|
||||
contact = key,
|
||||
limit = MESSAGES_PER_CONTACT,
|
||||
includeFiltered = false,
|
||||
getNode = { userId -> nodeRepository.getNode(userId ?: "") },
|
||||
)
|
||||
.first()
|
||||
}
|
||||
.sortedByDescending { it.receivedTime }
|
||||
.take(effectiveLimit)
|
||||
}
|
||||
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
val summaries =
|
||||
messages.map { msg ->
|
||||
MessageSummary(
|
||||
senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}",
|
||||
text = msg.text,
|
||||
contactName = resolveContactDisplayName(msg, channelSet),
|
||||
receivedTime = msg.receivedTime,
|
||||
fromLocal = msg.fromLocal,
|
||||
read = msg.read,
|
||||
)
|
||||
}
|
||||
|
||||
GetRecentMessagesResult.Success(summaries)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetRecentMessagesResult.Error("Failed to retrieve messages: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun getUnreadSummary(): GetUnreadSummaryResult = withTimeout(OPERATION_TIMEOUT) {
|
||||
try {
|
||||
val contacts = packetRepository.getContacts().first()
|
||||
val settings = packetRepository.getContactSettings().first()
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
val nodeMap = nodeRepository.nodeDBbyNum.first()
|
||||
|
||||
val nonMutedContacts = contacts.filter { (key, _) -> settings[key]?.isMuted != true }
|
||||
|
||||
val contactUnreads =
|
||||
nonMutedContacts.mapNotNull { (contactKey, lastPacket) ->
|
||||
val unreadCount = packetRepository.getUnreadCount(contactKey)
|
||||
if (unreadCount <= 0) return@mapNotNull null
|
||||
|
||||
val isBroadcast = lastPacket.to == DataPacket.ID_BROADCAST
|
||||
val displayName =
|
||||
if (isBroadcast) {
|
||||
val channelIndex = contactKey.firstOrNull()?.digitToIntOrNull() ?: 0
|
||||
channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
|
||||
?: "Channel $channelIndex"
|
||||
} else {
|
||||
val userId = lastPacket.from ?: ""
|
||||
val node = nodeMap.values.find { it.user.id == userId }
|
||||
node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown"
|
||||
}
|
||||
|
||||
ContactUnread(
|
||||
name = displayName,
|
||||
unreadCount = unreadCount,
|
||||
lastMessagePreview = lastPacket.text?.take(MESSAGE_PREVIEW_MAX_LENGTH),
|
||||
lastMessageTime = lastPacket.time.takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
|
||||
val totalUnread = contactUnreads.sumOf { it.unreadCount }
|
||||
|
||||
GetUnreadSummaryResult.Success(
|
||||
UnreadSummary(
|
||||
totalUnreadCount = totalUnread,
|
||||
contacts = contactUnreads.sortedByDescending { it.lastMessageTime },
|
||||
),
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) throw ex
|
||||
GetUnreadSummaryResult.Error("Failed to retrieve unread summary: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a contact name (node or channel) to a contact key for reading messages. Returns null if the name cannot
|
||||
* be resolved.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun resolveContactKeyForRead(name: String): String? {
|
||||
// Try node name first
|
||||
when (val nodeResult = fuzzyNameResolver.resolveNodeName(name)) {
|
||||
is NodeNameResult.Found -> {
|
||||
val channelIndex = DataPacket.PKC_CHANNEL_INDEX
|
||||
return "${channelIndex}${nodeResult.userId}"
|
||||
}
|
||||
|
||||
is NodeNameResult.Ambiguous -> return null
|
||||
|
||||
is NodeNameResult.NotFound -> {
|
||||
/* fall through to channel */
|
||||
}
|
||||
}
|
||||
|
||||
// Try channel name
|
||||
return when (val channelResult = fuzzyNameResolver.resolveChannelName(name)) {
|
||||
is ChannelNameResult.Found -> "${channelResult.channelIndex}${DataPacket.ID_BROADCAST}"
|
||||
is ChannelNameResult.Ambiguous -> null
|
||||
is ChannelNameResult.NotFound -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveContactDisplayName(
|
||||
msg: org.meshtastic.core.model.Message,
|
||||
channelSet: org.meshtastic.proto.ChannelSet,
|
||||
): String {
|
||||
// For broadcast messages, use channel name
|
||||
val channelIndex = msg.node.channel
|
||||
return channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
|
||||
?: "Channel $channelIndex"
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun resolveContactKey(recipientName: String?, channelName: String?): ResolvedContact? {
|
||||
// Direct message to a specific node
|
||||
if (recipientName != null) {
|
||||
return when (val result = fuzzyNameResolver.resolveNodeName(recipientName)) {
|
||||
is NodeNameResult.Found -> {
|
||||
// DM contact key format: channel_index + nodeId
|
||||
// For PKC DMs, use channel index 8; for legacy use no channel prefix
|
||||
val channelIndex = DataPacket.PKC_CHANNEL_INDEX
|
||||
ResolvedContact.Resolved(
|
||||
contactKey = "${channelIndex}${result.userId}",
|
||||
channelName = "DM to $recipientName",
|
||||
)
|
||||
}
|
||||
|
||||
is NodeNameResult.Ambiguous -> ResolvedContact.Ambiguous(result.candidates)
|
||||
|
||||
is NodeNameResult.NotFound -> {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to a specific channel
|
||||
if (channelName != null) {
|
||||
return when (val result = fuzzyNameResolver.resolveChannelName(channelName)) {
|
||||
is ChannelNameResult.Found ->
|
||||
ResolvedContact.Resolved(
|
||||
contactKey = "${result.channelIndex}${DataPacket.ID_BROADCAST}",
|
||||
channelName = result.name,
|
||||
)
|
||||
|
||||
is ChannelNameResult.Ambiguous -> ResolvedContact.Ambiguous(result.candidates)
|
||||
|
||||
is ChannelNameResult.NotFound -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Default: broadcast on primary channel (index 0)
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
val primaryName = channelSet.settings.firstOrNull()?.name?.ifBlank { "Primary" } ?: "Primary"
|
||||
return ResolvedContact.Resolved(contactKey = "0${DataPacket.ID_BROADCAST}", channelName = primaryName)
|
||||
}
|
||||
|
||||
private sealed class ResolvedContact {
|
||||
data class Resolved(val contactKey: String, val channelName: String) : ResolvedContact()
|
||||
|
||||
data class Ambiguous(val candidates: List<String>) : ResolvedContact()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val OPERATION_TIMEOUT = 5.seconds
|
||||
private const val MAX_BATTERY_LEVEL = 100
|
||||
private const val HEX_RADIX = 16
|
||||
private const val MS_PER_SEC = 1000L
|
||||
private const val HEALTH_SCORE_BASE = 50
|
||||
private const val HEALTH_SCORE_ONLINE_RATIO = 50
|
||||
private const val HEALTH_SCORE_DEGRADED = 10
|
||||
private const val HEALTH_SCORE_MAX = 100
|
||||
private const val MESSAGES_PER_CONTACT = 5
|
||||
private const val MESSAGE_PREVIEW_MAX_LENGTH = 100
|
||||
|
||||
/** Standard Meshtastic message payload limit (bytes). */
|
||||
const val MAX_MESSAGE_LENGTH = 237
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension to get a display name for ConnectionState. */
|
||||
private val ConnectionState.name: String
|
||||
get() =
|
||||
when (this) {
|
||||
ConnectionState.Connected -> "CONNECTED"
|
||||
ConnectionState.Connecting -> "CONNECTING"
|
||||
ConnectionState.Disconnected -> "DISCONNECTED"
|
||||
ConnectionState.DeviceSleep -> "DEVICE_SLEEP"
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
/** Result of a [AiFunctionProvider.sendMessage] invocation. */
|
||||
sealed class SendMessageResult {
|
||||
/** Message was successfully queued for transmission. */
|
||||
data class Success(val messageId: Int, val channel: String, val timestamp: Long) : SendMessageResult()
|
||||
|
||||
/** Device is not connected to a Meshtastic radio. */
|
||||
data class NotConnected(val message: String) : SendMessageResult()
|
||||
|
||||
/** The provided name matched multiple candidates. */
|
||||
data class AmbiguousName(val candidates: List<String>) : SendMessageResult()
|
||||
|
||||
/** An argument was invalid (e.g., message too long, name not found). */
|
||||
data class InvalidArgument(val reason: String) : SendMessageResult()
|
||||
|
||||
/** Rate limit exceeded — too many AI-triggered sends in the time window. */
|
||||
data class RateLimited(val retryAfterSeconds: Int) : SendMessageResult()
|
||||
}
|
||||
|
||||
/** Result of a [AiFunctionProvider.getMeshStatus] invocation. */
|
||||
data class MeshStatusResult(
|
||||
/** Current connection state (e.g., "CONNECTED", "DISCONNECTED"). */
|
||||
val connectionState: String,
|
||||
/** Number of nodes heard within the online threshold. */
|
||||
val onlineNodeCount: Int,
|
||||
/** Total number of nodes in the local database. */
|
||||
val totalNodeCount: Int,
|
||||
/** Local device battery level (0-100), or null if unavailable. */
|
||||
val localBatteryLevel: Int?,
|
||||
/** Display name of the local node, or null if not yet configured. */
|
||||
val localNodeName: String?,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getNodeList] invocation. */
|
||||
sealed class GetNodeListResult {
|
||||
/** Successfully retrieved the list of visible mesh nodes. */
|
||||
data class Success(val nodes: List<NodeSummary>) : GetNodeListResult()
|
||||
|
||||
/** Device is not connected to a Meshtastic radio. */
|
||||
data class NotConnected(val message: String) : GetNodeListResult()
|
||||
|
||||
/** An error occurred retrieving the node list. */
|
||||
data class Error(val reason: String) : GetNodeListResult()
|
||||
}
|
||||
|
||||
/** Summary information for a single mesh node. */
|
||||
data class NodeSummary(
|
||||
/** Node ID in Meshtastic hex format (e.g., "!abc12345"). */
|
||||
val id: String,
|
||||
/** Display name of the node. */
|
||||
val name: String,
|
||||
/** Battery level (0-100), or null if unavailable. */
|
||||
val batteryLevel: Int?,
|
||||
/** Last time this node was heard from (milliseconds since epoch). */
|
||||
val lastHeard: Long,
|
||||
/** Whether this node is currently considered online. */
|
||||
val isOnline: Boolean,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getChannelInfo] invocation. */
|
||||
sealed class GetChannelInfoResult {
|
||||
/** Successfully retrieved the list of channels. */
|
||||
data class Success(val channels: List<ChannelSummary>) : GetChannelInfoResult()
|
||||
|
||||
/** Device is not connected to a Meshtastic radio. */
|
||||
data class NotConnected(val message: String) : GetChannelInfoResult()
|
||||
|
||||
/** An error occurred retrieving channel info. */
|
||||
data class Error(val reason: String) : GetChannelInfoResult()
|
||||
}
|
||||
|
||||
/** Summary information for a single mesh channel. */
|
||||
data class ChannelSummary(
|
||||
/** Channel index (0-7). */
|
||||
val index: Int,
|
||||
/** Display name of the channel. */
|
||||
val name: String,
|
||||
/** Whether this is the primary/default channel. */
|
||||
val isPrimary: Boolean,
|
||||
/** Uplink enabled for this channel. */
|
||||
val uplinkEnabled: Boolean,
|
||||
/** Downlink enabled for this channel. */
|
||||
val downlinkEnabled: Boolean,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getDeviceStatus] invocation. */
|
||||
sealed class GetDeviceStatusResult {
|
||||
/** Successfully retrieved device status. */
|
||||
data class Success(val device: DeviceStatus) : GetDeviceStatusResult()
|
||||
|
||||
/** Device is not available or not connected. */
|
||||
data class NotAvailable(val message: String) : GetDeviceStatusResult()
|
||||
|
||||
/** An error occurred retrieving device status. */
|
||||
data class Error(val reason: String) : GetDeviceStatusResult()
|
||||
}
|
||||
|
||||
/** Status and metrics of the local mesh radio device. */
|
||||
data class DeviceStatus(
|
||||
/** Device model/hardware (e.g., "Meshtastic nRF52840"). */
|
||||
val model: String,
|
||||
/** Firmware version string. */
|
||||
val firmwareVersion: String,
|
||||
/** Battery level (0-100), or null if not battery-powered. */
|
||||
val batteryLevel: Int?,
|
||||
/** Charging status: "CHARGING", "NOT_CHARGING", or "UNKNOWN". */
|
||||
val chargingStatus: String,
|
||||
/** Display name of the device. */
|
||||
val deviceName: String?,
|
||||
/** Whether the radio is currently transmitting or receiving. */
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getNodeDetails] invocation. */
|
||||
sealed class GetNodeDetailsResult {
|
||||
/** Successfully retrieved node details. */
|
||||
data class Success(val node: NodeDetails) : GetNodeDetailsResult()
|
||||
|
||||
/** Device is not connected to a Meshtastic radio. */
|
||||
data class NotConnected(val message: String) : GetNodeDetailsResult()
|
||||
|
||||
/** Node with given ID not found. */
|
||||
data class NotFound(val message: String) : GetNodeDetailsResult()
|
||||
|
||||
/** An error occurred retrieving node details. */
|
||||
data class Error(val reason: String) : GetNodeDetailsResult()
|
||||
}
|
||||
|
||||
/** Detailed telemetry and status for a specific node. */
|
||||
data class NodeDetails(
|
||||
/** Node ID in Meshtastic hex format (e.g., "!abc12345"). */
|
||||
val id: String,
|
||||
/** User ID string for this node. */
|
||||
val userId: String,
|
||||
/** Display name of the node. */
|
||||
val name: String,
|
||||
/** Battery level (0-100), or null if unavailable. */
|
||||
val batteryLevel: Int?,
|
||||
/** Supply voltage in volts, or null if unavailable. */
|
||||
val voltage: Float?,
|
||||
/** Hardware model (e.g., "Meshtastic nRF52840"). */
|
||||
val hardwareModel: String,
|
||||
/** Firmware version string. */
|
||||
val firmwareVersion: String,
|
||||
/** Signal-to-noise ratio of the strongest received signal. */
|
||||
val snr: Float,
|
||||
/** Received signal strength indicator in dB. */
|
||||
val rssi: Int,
|
||||
/** Number of hops away from the local node (-1 if unknown). */
|
||||
val hopsAway: Int,
|
||||
/** Channel index this node is on. */
|
||||
val channel: Int,
|
||||
/** Last time this node was heard from (milliseconds since epoch). */
|
||||
val lastHeard: Long,
|
||||
/** User role or device type (e.g., "CLIENT", "REPEATER"). */
|
||||
val userRole: String,
|
||||
/** Whether user is licensed to operate this hardware. */
|
||||
val isLicensed: Boolean,
|
||||
/** Latitude (degrees), or null if not available. */
|
||||
val latitude: Double?,
|
||||
/** Longitude (degrees), or null if not available. */
|
||||
val longitude: Double?,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getMeshMetrics] invocation. */
|
||||
sealed class GetMeshMetricsResult {
|
||||
/** Successfully retrieved mesh metrics. */
|
||||
data class Success(val metrics: MeshMetrics) : GetMeshMetricsResult()
|
||||
|
||||
/** Device is not connected to a Meshtastic radio. */
|
||||
data class NotConnected(val message: String) : GetMeshMetricsResult()
|
||||
|
||||
/** An error occurred retrieving mesh metrics. */
|
||||
data class Error(val reason: String) : GetMeshMetricsResult()
|
||||
}
|
||||
|
||||
/** Aggregate network metrics and statistics for the entire mesh. */
|
||||
data class MeshMetrics(
|
||||
/** Total number of nodes known to this device. */
|
||||
val totalNodeCount: Int,
|
||||
/** Number of nodes that are currently online. */
|
||||
val onlineNodeCount: Int,
|
||||
/** Average battery level across all nodes, or null if unknown. */
|
||||
val averageBatteryLevel: Int?,
|
||||
/** Estimated mesh health score (0-100), based on connectivity and node activity. */
|
||||
val meshHealthScore: Int,
|
||||
/** Timestamp of the most recent packet received (milliseconds since epoch). */
|
||||
val mostRecentPacketTime: Long,
|
||||
/** Mesh uptime since local node startup (seconds). */
|
||||
val meshUptimeSeconds: Long,
|
||||
/** Estimated channel utilization percentage (0-100), or null if unavailable. */
|
||||
val channelUtilizationPercent: Int?,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getRecentMessages] invocation. */
|
||||
sealed class GetRecentMessagesResult {
|
||||
/** Successfully retrieved recent messages. */
|
||||
data class Success(val messages: List<MessageSummary>) : GetRecentMessagesResult()
|
||||
|
||||
/** The specified contact was not found via fuzzy matching. */
|
||||
data class ContactNotFound(val message: String) : GetRecentMessagesResult()
|
||||
|
||||
/** An error occurred retrieving messages. */
|
||||
data class Error(val reason: String) : GetRecentMessagesResult()
|
||||
}
|
||||
|
||||
/** Summary of a single mesh message suitable for AI consumption. */
|
||||
data class MessageSummary(
|
||||
/** Display name of the message sender. */
|
||||
val senderName: String,
|
||||
/** The message text content. */
|
||||
val text: String,
|
||||
/** Channel or contact name this message belongs to. */
|
||||
val contactName: String,
|
||||
/** When the message was received (milliseconds since epoch). */
|
||||
val receivedTime: Long,
|
||||
/** Whether this message was sent by the local user. */
|
||||
val fromLocal: Boolean,
|
||||
/** Whether this message has been read. */
|
||||
val read: Boolean,
|
||||
)
|
||||
|
||||
/** Result of a [AiFunctionProvider.getUnreadSummary] invocation. */
|
||||
sealed class GetUnreadSummaryResult {
|
||||
/** Successfully retrieved unread summary. */
|
||||
data class Success(val summary: UnreadSummary) : GetUnreadSummaryResult()
|
||||
|
||||
/** An error occurred retrieving unread summary. */
|
||||
data class Error(val reason: String) : GetUnreadSummaryResult()
|
||||
}
|
||||
|
||||
/** Unread message summary across all contacts. */
|
||||
data class UnreadSummary(
|
||||
/** Total number of unread messages across all contacts. */
|
||||
val totalUnreadCount: Int,
|
||||
/** Per-contact breakdown of unread messages (excludes muted contacts). */
|
||||
val contacts: List<ContactUnread>,
|
||||
)
|
||||
|
||||
/** Unread info for a single contact or channel. */
|
||||
data class ContactUnread(
|
||||
/** Display name of the contact or channel. */
|
||||
val name: String,
|
||||
/** Number of unread messages from this contact. */
|
||||
val unreadCount: Int,
|
||||
/** Preview of the last message text, or null if none. */
|
||||
val lastMessagePreview: String?,
|
||||
/** Timestamp of the last message (milliseconds since epoch), or null if none. */
|
||||
val lastMessageTime: Long?,
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
|
||||
/**
|
||||
* Resolves fuzzy node and channel name queries to concrete identifiers.
|
||||
*
|
||||
* Uses longest-common-substring matching with a minimum threshold of 50% of the query length. Returns an error with
|
||||
* candidate list if ambiguous.
|
||||
*/
|
||||
@Single
|
||||
class FuzzyNameResolver(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
) {
|
||||
|
||||
/** Resolve a node name query to a node number and user ID. */
|
||||
fun resolveNodeName(query: String): NodeNameResult {
|
||||
val nodes = nodeRepository.nodeDBbyNum.value
|
||||
val candidates =
|
||||
nodes.values
|
||||
.filter { it.user.long_name.isNotBlank() }
|
||||
.map { NameCandidate(it.user.long_name, it.num, it.user.id) }
|
||||
|
||||
return matchName(query, candidates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a channel name query to a channel index.
|
||||
*
|
||||
* Admin channels are excluded from resolution (NFR-001).
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
suspend fun resolveChannelName(query: String): ChannelNameResult {
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
val candidates =
|
||||
channelSet.settings
|
||||
.mapIndexed { index, settings -> IndexedChannel(settings.name, index) }
|
||||
.filter { it.name.isNotBlank() }
|
||||
// Exclude admin channels (convention: channel named "admin" is sensitive)
|
||||
.filter { !it.name.equals("admin", ignoreCase = true) }
|
||||
|
||||
if (candidates.isEmpty()) return ChannelNameResult.NotFound
|
||||
|
||||
// Exact match first
|
||||
candidates
|
||||
.firstOrNull { it.name.equals(query, ignoreCase = true) }
|
||||
?.let {
|
||||
return ChannelNameResult.Found(it.index, it.name)
|
||||
}
|
||||
|
||||
// Fuzzy match
|
||||
val scored =
|
||||
candidates
|
||||
.map { it to longestCommonSubstringLength(query.lowercase(), it.name.lowercase()) }
|
||||
.filter { (_, score) -> score >= (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1) }
|
||||
.sortedByDescending { it.second }
|
||||
|
||||
return when {
|
||||
scored.isEmpty() -> ChannelNameResult.NotFound
|
||||
scored.size == 1 -> ChannelNameResult.Found(scored[0].first.index, scored[0].first.name)
|
||||
scored[0].second > scored[1].second -> ChannelNameResult.Found(scored[0].first.index, scored[0].first.name)
|
||||
else -> ChannelNameResult.Ambiguous(scored.map { it.first.name })
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun matchName(query: String, candidates: List<NameCandidate>): NodeNameResult {
|
||||
if (candidates.isEmpty()) return NodeNameResult.NotFound
|
||||
|
||||
// Exact match first (case-insensitive)
|
||||
candidates
|
||||
.firstOrNull { it.name.equals(query, ignoreCase = true) }
|
||||
?.let {
|
||||
return NodeNameResult.Found(it.nodeNum, it.userId)
|
||||
}
|
||||
|
||||
// Fuzzy match using longest common substring
|
||||
val minScore = (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1)
|
||||
val scored =
|
||||
candidates
|
||||
.map { it to longestCommonSubstringLength(query.lowercase(), it.name.lowercase()) }
|
||||
.filter { (_, score) -> score >= minScore }
|
||||
.sortedByDescending { it.second }
|
||||
|
||||
return when {
|
||||
scored.isEmpty() -> NodeNameResult.NotFound
|
||||
|
||||
scored.size == 1 -> NodeNameResult.Found(scored[0].first.nodeNum, scored[0].first.userId)
|
||||
|
||||
scored[0].second > scored[1].second -> {
|
||||
// Clear winner — top score is strictly greater
|
||||
NodeNameResult.Found(scored[0].first.nodeNum, scored[0].first.userId)
|
||||
}
|
||||
|
||||
else -> NodeNameResult.Ambiguous(scored.map { it.first.name })
|
||||
}
|
||||
}
|
||||
|
||||
private data class NameCandidate(val name: String, val nodeNum: Int, val userId: String)
|
||||
|
||||
private data class IndexedChannel(val name: String, val index: Int)
|
||||
|
||||
companion object {
|
||||
/** Minimum match ratio — longest common substring must be ≥50% of query length. */
|
||||
const val MATCH_THRESHOLD = 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the length of the longest common substring between two strings. */
|
||||
internal fun longestCommonSubstringLength(a: String, b: String): Int {
|
||||
if (a.isEmpty() || b.isEmpty()) return 0
|
||||
var maxLen = 0
|
||||
// Space-optimized: only need previous row
|
||||
val prev = IntArray(b.length + 1)
|
||||
val curr = IntArray(b.length + 1)
|
||||
for (i in 1..a.length) {
|
||||
for (j in 1..b.length) {
|
||||
curr[j] =
|
||||
if (a[i - 1] == b[j - 1]) {
|
||||
(prev[j - 1] + 1).also { if (it > maxLen) maxLen = it }
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
prev.indices.forEach {
|
||||
prev[it] = curr[it]
|
||||
curr[it] = 0
|
||||
}
|
||||
}
|
||||
return maxLen
|
||||
}
|
||||
|
||||
sealed class NodeNameResult {
|
||||
data class Found(val nodeNum: Int, val userId: String) : NodeNameResult()
|
||||
|
||||
data class Ambiguous(val candidates: List<String>) : NodeNameResult()
|
||||
|
||||
data object NotFound : NodeNameResult()
|
||||
}
|
||||
|
||||
sealed class ChannelNameResult {
|
||||
data class Found(val channelIndex: Int, val name: String) : ChannelNameResult()
|
||||
|
||||
data class Ambiguous(val candidates: List<String>) : ChannelNameResult()
|
||||
|
||||
data object NotFound : ChannelNameResult()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter for AI-triggered operations.
|
||||
*
|
||||
* Tracks the last [maxCalls] invocation timestamps. A new call is permitted only if fewer than [maxCalls] occurred
|
||||
* within the [windowDuration]. This prevents aggregate AI traffic from flooding the mesh network.
|
||||
*
|
||||
* The limiter is intentionally process-scoped and global so concurrent AI surfaces share a single airtime budget.
|
||||
*/
|
||||
@Single
|
||||
class RateLimiter(private val clock: Clock) {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val timestamps = ArrayDeque<Instant>(MAX_CALLS)
|
||||
|
||||
/**
|
||||
* Attempt to acquire a permit for one invocation.
|
||||
*
|
||||
* @return [RateLimitResult.Permitted] if under the limit, or [RateLimitResult.Limited] with the number of seconds
|
||||
* until a slot frees up.
|
||||
*/
|
||||
suspend fun tryAcquire(): RateLimitResult = mutex.withLock {
|
||||
val now = clock.now()
|
||||
val windowStart = now - WINDOW_DURATION
|
||||
|
||||
// Evict timestamps outside the window
|
||||
while (timestamps.isNotEmpty() && timestamps.first() <= windowStart) {
|
||||
timestamps.removeFirst()
|
||||
}
|
||||
|
||||
return if (timestamps.size < MAX_CALLS) {
|
||||
timestamps.addLast(now)
|
||||
RateLimitResult.Permitted
|
||||
} else {
|
||||
val oldestInWindow = timestamps.first()
|
||||
val retryAfter = ((oldestInWindow + WINDOW_DURATION) - now).inWholeSeconds.toInt() + 1
|
||||
RateLimitResult.Limited(retryAfterSeconds = retryAfter.coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_CALLS = 5
|
||||
val WINDOW_DURATION = 60.seconds
|
||||
}
|
||||
}
|
||||
|
||||
sealed class RateLimitResult {
|
||||
data object Permitted : RateLimitResult()
|
||||
|
||||
data class Limited(val retryAfterSeconds: Int) : RateLimitResult()
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
class AiFunctionProviderImplTest {
|
||||
|
||||
private val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
|
||||
private val serviceRepository: ServiceRepository =
|
||||
mock(MockMode.autofill) { every { connectionState } returns this@AiFunctionProviderImplTest.connectionState }
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill)
|
||||
private val fuzzyNameResolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
private val packetRepository: PacketRepository = mock(MockMode.autofill)
|
||||
private val clock = TestClock(Instant.fromEpochSeconds(1_700_000_000))
|
||||
private val rateLimiter = RateLimiter(clock)
|
||||
|
||||
private fun createProvider() = AiFunctionProviderImpl(
|
||||
serviceRepository = serviceRepository,
|
||||
nodeRepository = nodeRepository,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
sendMessageUseCase = sendMessageUseCase,
|
||||
fuzzyNameResolver = fuzzyNameResolver,
|
||||
packetRepository = packetRepository,
|
||||
rateLimiter = rateLimiter,
|
||||
clock = clock,
|
||||
)
|
||||
|
||||
// --- getNodeDetails tests ---
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_returns_not_connected_when_disconnected() = runTest {
|
||||
connectionState.value = ConnectionState.Disconnected
|
||||
val provider = createProvider()
|
||||
|
||||
val result = provider.getNodeDetails("!abc123")
|
||||
assertIs<GetNodeDetailsResult.NotConnected>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_returns_not_found_for_unknown_node() = runTest {
|
||||
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getNodeDetails("!ffffff")
|
||||
|
||||
assertIs<GetNodeDetailsResult.NotFound>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_returns_node_data_for_valid_hex_id() = runTest {
|
||||
val testNode =
|
||||
Node(
|
||||
num = 0xabc,
|
||||
user = User(id = "!00000abc", long_name = "Alice", short_name = "AL"),
|
||||
lastHeard = 1_700_000_000,
|
||||
snr = 5.5f,
|
||||
rssi = -70,
|
||||
channel = 0,
|
||||
hopsAway = 1,
|
||||
)
|
||||
val nodeMap = MutableStateFlow(mapOf(0xabc to testNode))
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getNodeDetails("!abc")
|
||||
|
||||
assertIs<GetNodeDetailsResult.Success>(result)
|
||||
assertEquals("Alice", result.node.name)
|
||||
assertEquals(5.5f, result.node.snr)
|
||||
assertEquals(-70, result.node.rssi)
|
||||
assertEquals(1, result.node.hopsAway)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_returns_null_position_when_no_fix() = runTest {
|
||||
// Node with (0.0, 0.0) position and time=0 → no valid position
|
||||
val testNode = Node(num = 1, user = User(id = "!00000001", long_name = "NoGPS", short_name = "NG"))
|
||||
val nodeMap = MutableStateFlow(mapOf(1 to testNode))
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getNodeDetails("!1")
|
||||
|
||||
assertIs<GetNodeDetailsResult.Success>(result)
|
||||
assertNull(result.node.latitude)
|
||||
assertNull(result.node.longitude)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeDetails_returns_error_for_invalid_hex_format() = runTest {
|
||||
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getNodeDetails("!not_hex")
|
||||
|
||||
// Invalid hex should result in NotFound or Error
|
||||
val isHandled = result is GetNodeDetailsResult.NotFound || result is GetNodeDetailsResult.Error
|
||||
assertEquals(true, isHandled)
|
||||
}
|
||||
|
||||
// --- getMeshMetrics tests ---
|
||||
|
||||
@Test
|
||||
fun getMeshMetrics_returns_not_connected_when_disconnected() = runTest {
|
||||
connectionState.value = ConnectionState.Disconnected
|
||||
val provider = createProvider()
|
||||
|
||||
val result = provider.getMeshMetrics()
|
||||
assertIs<GetMeshMetricsResult.NotConnected>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMeshMetrics_returns_valid_metrics_with_active_nodes() = runTest {
|
||||
val nodes = mapOf(1 to Node(num = 1, lastHeard = 1_699_999_990), 2 to Node(num = 2, lastHeard = 1_699_999_980))
|
||||
val nodeMap = MutableStateFlow(nodes)
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
every { nodeRepository.totalNodeCount } returns flowOf(2)
|
||||
every { nodeRepository.onlineNodeCount } returns flowOf(2)
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getMeshMetrics()
|
||||
|
||||
assertIs<GetMeshMetricsResult.Success>(result)
|
||||
assertEquals(2, result.metrics.totalNodeCount)
|
||||
assertEquals(2, result.metrics.onlineNodeCount)
|
||||
// Health score: 50 + (50 * 2) / 2 = 100
|
||||
assertEquals(100, result.metrics.meshHealthScore)
|
||||
// Most recent packet: 1_699_999_990 * 1000
|
||||
assertEquals(1_699_999_990_000L, result.metrics.mostRecentPacketTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMeshMetrics_returns_zero_health_score_when_empty() = runTest {
|
||||
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
every { nodeRepository.totalNodeCount } returns flowOf(0)
|
||||
every { nodeRepository.onlineNodeCount } returns flowOf(0)
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getMeshMetrics()
|
||||
|
||||
assertIs<GetMeshMetricsResult.Success>(result)
|
||||
assertEquals(0, result.metrics.totalNodeCount)
|
||||
assertEquals(0, result.metrics.meshHealthScore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMeshMetrics_falls_back_to_current_time_when_all_lastHeard_zero() = runTest {
|
||||
val nodes = mapOf(1 to Node(num = 1, lastHeard = 0))
|
||||
val nodeMap = MutableStateFlow(nodes)
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
every { nodeRepository.totalNodeCount } returns flowOf(1)
|
||||
every { nodeRepository.onlineNodeCount } returns flowOf(0)
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getMeshMetrics()
|
||||
|
||||
assertIs<GetMeshMetricsResult.Success>(result)
|
||||
// Falls back to clock.now() since all lastHeard are 0
|
||||
assertEquals(clock.now().toEpochMilliseconds(), result.metrics.mostRecentPacketTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMeshMetrics_returns_degraded_health_when_no_nodes_online() = runTest {
|
||||
val nodes = mapOf(1 to Node(num = 1, lastHeard = 1_000))
|
||||
val nodeMap = MutableStateFlow(nodes)
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
every { nodeRepository.totalNodeCount } returns flowOf(1)
|
||||
every { nodeRepository.onlineNodeCount } returns flowOf(0)
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getMeshMetrics()
|
||||
|
||||
assertIs<GetMeshMetricsResult.Success>(result)
|
||||
// HEALTH_SCORE_DEGRADED = 10
|
||||
assertEquals(10, result.metrics.meshHealthScore)
|
||||
}
|
||||
|
||||
// --- sendMessage error propagation test ---
|
||||
|
||||
@Test
|
||||
fun sendMessage_returns_not_connected_when_disconnected() = runTest {
|
||||
connectionState.value = ConnectionState.Disconnected
|
||||
val provider = createProvider()
|
||||
|
||||
val result = provider.sendMessage("hello", null, null)
|
||||
assertIs<SendMessageResult.NotConnected>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_returns_rate_limited_when_exhausted() = runTest {
|
||||
val provider = createProvider()
|
||||
|
||||
// Exhaust rate limit
|
||||
repeat(RateLimiter.MAX_CALLS) { rateLimiter.tryAcquire() }
|
||||
|
||||
val result = provider.sendMessage("hello", null, null)
|
||||
assertIs<SendMessageResult.RateLimited>(result)
|
||||
}
|
||||
|
||||
// --- getRecentMessages tests ---
|
||||
|
||||
@Test
|
||||
fun getRecentMessages_contact_not_found() = runTest {
|
||||
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeMap
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getRecentMessages("NonExistent", 10)
|
||||
assertIs<GetRecentMessagesResult.ContactNotFound>(result)
|
||||
}
|
||||
|
||||
// --- getUnreadSummary tests ---
|
||||
|
||||
@Test
|
||||
fun getUnreadSummary_returns_empty_when_no_unread() = runTest {
|
||||
every { packetRepository.getContacts() } returns flowOf(emptyMap())
|
||||
every { packetRepository.getContactSettings() } returns flowOf(emptyMap())
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
|
||||
val provider = createProvider()
|
||||
val result = provider.getUnreadSummary()
|
||||
assertIs<GetUnreadSummaryResult.Success>(result)
|
||||
assertEquals(0, result.summary.totalUnreadCount)
|
||||
assertEquals(0, result.summary.contacts.size)
|
||||
}
|
||||
}
|
||||
|
||||
private class TestClock(var currentTime: Instant) : Clock {
|
||||
override fun now(): Instant = currentTime
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.ai
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class FuzzyNameResolverTest {
|
||||
|
||||
@Test
|
||||
fun longestCommonSubstring_exact_match() {
|
||||
assertEquals(5, longestCommonSubstringLength("hello", "hello"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longestCommonSubstring_partial_match() {
|
||||
assertEquals(3, longestCommonSubstringLength("abcdef", "xbcdx"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longestCommonSubstring_no_match() {
|
||||
assertEquals(0, longestCommonSubstringLength("abc", "xyz"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longestCommonSubstring_empty_string() {
|
||||
assertEquals(0, longestCommonSubstringLength("", "abc"))
|
||||
assertEquals(0, longestCommonSubstringLength("abc", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longestCommonSubstring_case_sensitive() {
|
||||
// The function itself is case-sensitive; callers lowercase
|
||||
assertEquals(0, longestCommonSubstringLength("ABC", "abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longestCommonSubstring_longer_second() {
|
||||
assertEquals(4, longestCommonSubstringLength("test", "this is a test string"))
|
||||
}
|
||||
|
||||
// NodeNameResult / ChannelNameResult sealed classes are tested indirectly via
|
||||
// the integration with AiFunctionProviderImpl, but we verify basic structure here.
|
||||
|
||||
@Test
|
||||
fun nodeNameResult_found_carries_data() {
|
||||
val result = NodeNameResult.Found(nodeNum = 42, userId = "!abcd1234")
|
||||
assertIs<NodeNameResult.Found>(result)
|
||||
assertEquals(42, result.nodeNum)
|
||||
assertEquals("!abcd1234", result.userId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeNameResult_ambiguous_carries_candidates() {
|
||||
val result = NodeNameResult.Ambiguous(listOf("Alice", "Alicia"))
|
||||
assertIs<NodeNameResult.Ambiguous>(result)
|
||||
assertEquals(2, result.candidates.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelNameResult_found_carries_data() {
|
||||
val result = ChannelNameResult.Found(channelIndex = 1, name = "General")
|
||||
assertIs<ChannelNameResult.Found>(result)
|
||||
assertEquals(1, result.channelIndex)
|
||||
assertEquals("General", result.name)
|
||||
}
|
||||
|
||||
// --- Behavioral tests for resolveNodeName ---
|
||||
|
||||
@Test
|
||||
fun resolveNodeName_exact_match_case_insensitive() {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val nodes =
|
||||
mapOf(
|
||||
1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")),
|
||||
2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
|
||||
)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveNodeName("alice")
|
||||
|
||||
assertIs<NodeNameResult.Found>(result)
|
||||
assertEquals(1, result.nodeNum)
|
||||
assertEquals("!00000001", result.userId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveNodeName_fuzzy_match_single_candidate() {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val nodes =
|
||||
mapOf(
|
||||
1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alexander", short_name = "AX")),
|
||||
2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
|
||||
)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveNodeName("Alexan")
|
||||
|
||||
assertIs<NodeNameResult.Found>(result)
|
||||
assertEquals(1, result.nodeNum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveNodeName_ambiguous_returns_candidates() {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val nodes =
|
||||
mapOf(
|
||||
1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice Smith", short_name = "AS")),
|
||||
2 to Node(num = 2, user = User(id = "!00000002", long_name = "Alice Jones", short_name = "AJ")),
|
||||
)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveNodeName("Alice")
|
||||
|
||||
// "Alice" matches both equally via LCS
|
||||
assertIs<NodeNameResult.Ambiguous>(result)
|
||||
assertEquals(2, result.candidates.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveNodeName_not_found_when_no_nodes() {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveNodeName("Unknown")
|
||||
|
||||
assertIs<NodeNameResult.NotFound>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveNodeName_not_found_when_no_match() {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val nodes = mapOf(1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")))
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveNodeName("Zzzzzz")
|
||||
|
||||
assertIs<NodeNameResult.NotFound>(result)
|
||||
}
|
||||
|
||||
// --- Behavioral tests for resolveChannelName ---
|
||||
|
||||
@Test
|
||||
fun resolveChannelName_exact_match() = runTest {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val channelSet =
|
||||
ChannelSet(settings = listOf(ChannelSettings(name = "General"), ChannelSettings(name = "Emergency")))
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveChannelName("General")
|
||||
|
||||
assertIs<ChannelNameResult.Found>(result)
|
||||
assertEquals(0, result.channelIndex)
|
||||
assertEquals("General", result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChannelName_excludes_admin_channel() = runTest {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val channelSet =
|
||||
ChannelSet(settings = listOf(ChannelSettings(name = "admin"), ChannelSettings(name = "General")))
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveChannelName("admin")
|
||||
|
||||
// "admin" should be excluded — cannot resolve to the admin channel
|
||||
assertIs<ChannelNameResult.NotFound>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveChannelName_not_found_when_empty() = runTest {
|
||||
val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
val channelSet = ChannelSet(settings = emptyList())
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
|
||||
|
||||
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
|
||||
val result = resolver.resolveChannelName("General")
|
||||
|
||||
assertIs<ChannelNameResult.NotFound>(result)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -168,6 +168,8 @@ sealed interface SettingsRoute : Route {
|
||||
|
||||
@Serializable data object NodeList : SettingsRoute
|
||||
|
||||
@Serializable data object AppFunctionsSettings : SettingsRoute
|
||||
|
||||
// endregion
|
||||
|
||||
// region help & documentation routes
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.prefs.appfunctions
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.AppFunctionsPrefs
|
||||
|
||||
@Single
|
||||
@Suppress("TooManyFunctions")
|
||||
class AppFunctionsPrefsImpl(
|
||||
@Named("AppDataStore") private val dataStore: DataStore<Preferences>,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) : AppFunctionsPrefs {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
override val masterEnabled: StateFlow<Boolean> = booleanPref(KEY_MASTER, true)
|
||||
override val sendMessageEnabled: StateFlow<Boolean> = booleanPref(KEY_SEND_MESSAGE, true)
|
||||
override val getMeshStatusEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_MESH_STATUS, true)
|
||||
override val getNodeListEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_NODE_LIST, true)
|
||||
override val getChannelInfoEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_CHANNEL_INFO, true)
|
||||
override val getDeviceStatusEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_DEVICE_STATUS, true)
|
||||
override val getNodeDetailsEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_NODE_DETAILS, true)
|
||||
override val getMeshMetricsEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_MESH_METRICS, true)
|
||||
override val getRecentMessagesEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_RECENT_MESSAGES, true)
|
||||
override val getUnreadSummaryEnabled: StateFlow<Boolean> = booleanPref(KEY_GET_UNREAD_SUMMARY, true)
|
||||
|
||||
override fun setMasterEnabled(enabled: Boolean) = set(KEY_MASTER, enabled)
|
||||
|
||||
override fun setSendMessageEnabled(enabled: Boolean) = set(KEY_SEND_MESSAGE, enabled)
|
||||
|
||||
override fun setGetMeshStatusEnabled(enabled: Boolean) = set(KEY_GET_MESH_STATUS, enabled)
|
||||
|
||||
override fun setGetNodeListEnabled(enabled: Boolean) = set(KEY_GET_NODE_LIST, enabled)
|
||||
|
||||
override fun setGetChannelInfoEnabled(enabled: Boolean) = set(KEY_GET_CHANNEL_INFO, enabled)
|
||||
|
||||
override fun setGetDeviceStatusEnabled(enabled: Boolean) = set(KEY_GET_DEVICE_STATUS, enabled)
|
||||
|
||||
override fun setGetNodeDetailsEnabled(enabled: Boolean) = set(KEY_GET_NODE_DETAILS, enabled)
|
||||
|
||||
override fun setGetMeshMetricsEnabled(enabled: Boolean) = set(KEY_GET_MESH_METRICS, enabled)
|
||||
|
||||
override fun setGetRecentMessagesEnabled(enabled: Boolean) = set(KEY_GET_RECENT_MESSAGES, enabled)
|
||||
|
||||
override fun setGetUnreadSummaryEnabled(enabled: Boolean) = set(KEY_GET_UNREAD_SUMMARY, enabled)
|
||||
|
||||
private fun booleanPref(key: Preferences.Key<Boolean>, default: Boolean): StateFlow<Boolean> =
|
||||
dataStore.data.map { it[key] ?: default }.stateIn(scope, SharingStarted.Eagerly, default)
|
||||
|
||||
private fun set(key: Preferences.Key<Boolean>, value: Boolean) {
|
||||
scope.launch { dataStore.edit { prefs -> prefs[key] = value } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_MASTER = booleanPreferencesKey("appfn_master_enabled")
|
||||
private val KEY_SEND_MESSAGE = booleanPreferencesKey("appfn_send_message")
|
||||
private val KEY_GET_MESH_STATUS = booleanPreferencesKey("appfn_get_mesh_status")
|
||||
private val KEY_GET_NODE_LIST = booleanPreferencesKey("appfn_get_node_list")
|
||||
private val KEY_GET_CHANNEL_INFO = booleanPreferencesKey("appfn_get_channel_info")
|
||||
private val KEY_GET_DEVICE_STATUS = booleanPreferencesKey("appfn_get_device_status")
|
||||
private val KEY_GET_NODE_DETAILS = booleanPreferencesKey("appfn_get_node_details")
|
||||
private val KEY_GET_MESH_METRICS = booleanPreferencesKey("appfn_get_mesh_metrics")
|
||||
private val KEY_GET_RECENT_MESSAGES = booleanPreferencesKey("appfn_get_recent_messages")
|
||||
private val KEY_GET_UNREAD_SUMMARY = booleanPreferencesKey("appfn_get_unread_summary")
|
||||
}
|
||||
}
|
||||
@@ -296,9 +296,53 @@ interface TakPrefs {
|
||||
fun setTakServerEnabled(enabled: Boolean)
|
||||
}
|
||||
|
||||
/** Reactive interface for App Functions (system AI integration) preferences. */
|
||||
interface AppFunctionsPrefs {
|
||||
val masterEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setMasterEnabled(enabled: Boolean)
|
||||
|
||||
val sendMessageEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setSendMessageEnabled(enabled: Boolean)
|
||||
|
||||
val getMeshStatusEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetMeshStatusEnabled(enabled: Boolean)
|
||||
|
||||
val getNodeListEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetNodeListEnabled(enabled: Boolean)
|
||||
|
||||
val getChannelInfoEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetChannelInfoEnabled(enabled: Boolean)
|
||||
|
||||
val getDeviceStatusEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetDeviceStatusEnabled(enabled: Boolean)
|
||||
|
||||
val getNodeDetailsEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetNodeDetailsEnabled(enabled: Boolean)
|
||||
|
||||
val getMeshMetricsEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetMeshMetricsEnabled(enabled: Boolean)
|
||||
|
||||
val getRecentMessagesEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetRecentMessagesEnabled(enabled: Boolean)
|
||||
|
||||
val getUnreadSummaryEnabled: StateFlow<Boolean>
|
||||
|
||||
fun setGetUnreadSummaryEnabled(enabled: Boolean)
|
||||
}
|
||||
|
||||
/** Consolidated interface for all application preferences. */
|
||||
interface AppPreferences {
|
||||
val analytics: AnalyticsPrefs
|
||||
val appFunctions: AppFunctionsPrefs
|
||||
val homoglyph: HomoglyphPrefs
|
||||
val filter: FilterPrefs
|
||||
val meshLog: MeshLogPrefs
|
||||
|
||||
@@ -44,7 +44,11 @@ import kotlin.random.Random
|
||||
* This implementation is platform-agnostic and relies on injected repositories and controllers.
|
||||
*/
|
||||
interface SendMessageUseCase {
|
||||
suspend operator fun invoke(text: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null)
|
||||
suspend operator fun invoke(
|
||||
text: String,
|
||||
contactKey: String = "0${DataPacket.ID_BROADCAST}",
|
||||
replyId: Int? = null,
|
||||
): Int
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
@@ -64,7 +68,7 @@ class SendMessageUseCaseImpl(
|
||||
* @param replyId Optional ID of a message being replied to.
|
||||
*/
|
||||
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
|
||||
override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?) {
|
||||
override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?): Int {
|
||||
val channel = contactKey[0].digitToIntOrNull()
|
||||
val dest = if (channel != null) contactKey.substring(1) else contactKey
|
||||
|
||||
@@ -125,7 +129,10 @@ class SendMessageUseCaseImpl(
|
||||
messageQueue.enqueue(packetId)
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "Failed to enqueue message packet" }
|
||||
throw ex
|
||||
}
|
||||
|
||||
return packetId
|
||||
}
|
||||
|
||||
private suspend fun favoriteNode(node: Node) {
|
||||
|
||||
@@ -86,6 +86,22 @@
|
||||
<string name="analytics_okay">Allow analytics and crash reporting.</string>
|
||||
<string name="analytics_platforms">Analytics platforms:</string>
|
||||
<string name="any">Any</string>
|
||||
<!-- APP -->
|
||||
<string name="app_functions_get_channel_info">Get channel info</string>
|
||||
<string name="app_functions_get_device_status">Get device status</string>
|
||||
<string name="app_functions_get_mesh_metrics">Get mesh metrics</string>
|
||||
<string name="app_functions_get_mesh_status">Get mesh status</string>
|
||||
<string name="app_functions_get_node_details">Get node details</string>
|
||||
<string name="app_functions_get_node_list">Get node list</string>
|
||||
<string name="app_functions_get_recent_messages">Get recent messages</string>
|
||||
<string name="app_functions_get_unread_summary">Get unread summary</string>
|
||||
<string name="app_functions_master_summary">Let system AI assistants (e.g. Gemini) discover and use mesh functions</string>
|
||||
<string name="app_functions_master_toggle">Allow AI access</string>
|
||||
<string name="app_functions_read_section">Read functions</string>
|
||||
<string name="app_functions_send_message">Send message</string>
|
||||
<string name="app_functions_settings">System AI</string>
|
||||
<string name="app_functions_settings_summary">Control which functions are available to AI assistants</string>
|
||||
<string name="app_functions_write_section">Write functions</string>
|
||||
<string name="app_notifications">App Notifications</string>
|
||||
<string name="app_settings">App</string>
|
||||
<string name="app_too_old">Application update required</string>
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.meshtastic.core.testing
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.core.repository.AnalyticsPrefs
|
||||
import org.meshtastic.core.repository.AppFunctionsPrefs
|
||||
import org.meshtastic.core.repository.AppPreferences
|
||||
import org.meshtastic.core.repository.CustomEmojiPrefs
|
||||
import org.meshtastic.core.repository.FilterPrefs
|
||||
@@ -336,8 +337,71 @@ class FakeMeshPrefs : MeshPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAppFunctionsPrefs : AppFunctionsPrefs {
|
||||
override val masterEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setMasterEnabled(enabled: Boolean) {
|
||||
masterEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val sendMessageEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setSendMessageEnabled(enabled: Boolean) {
|
||||
sendMessageEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getMeshStatusEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetMeshStatusEnabled(enabled: Boolean) {
|
||||
getMeshStatusEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getNodeListEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetNodeListEnabled(enabled: Boolean) {
|
||||
getNodeListEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getChannelInfoEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetChannelInfoEnabled(enabled: Boolean) {
|
||||
getChannelInfoEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getDeviceStatusEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetDeviceStatusEnabled(enabled: Boolean) {
|
||||
getDeviceStatusEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getNodeDetailsEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetNodeDetailsEnabled(enabled: Boolean) {
|
||||
getNodeDetailsEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getMeshMetricsEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetMeshMetricsEnabled(enabled: Boolean) {
|
||||
getMeshMetricsEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getRecentMessagesEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetRecentMessagesEnabled(enabled: Boolean) {
|
||||
getRecentMessagesEnabled.value = enabled
|
||||
}
|
||||
|
||||
override val getUnreadSummaryEnabled = MutableStateFlow(true)
|
||||
|
||||
override fun setGetUnreadSummaryEnabled(enabled: Boolean) {
|
||||
getUnreadSummaryEnabled.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAppPreferences : AppPreferences {
|
||||
override val analytics = FakeAnalyticsPrefs()
|
||||
override val appFunctions = FakeAppFunctionsPrefs()
|
||||
override val homoglyph = FakeHomoglyphPrefs()
|
||||
override val filter = FakeFilterPrefs()
|
||||
override val meshLog = FakeMeshLogPrefs()
|
||||
|
||||
@@ -179,7 +179,7 @@ class MessageViewModelTest {
|
||||
|
||||
@Test
|
||||
fun testSendMessage() = runTest {
|
||||
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns Unit
|
||||
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 1
|
||||
|
||||
viewModel.sendMessage("Hello", "0!12345678", null)
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.eygraber.uri.toKmpUri
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.qualifier.named
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
@@ -44,6 +46,8 @@ import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoute
|
||||
import org.meshtastic.core.navigation.WifiProvisionRoute
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_functions_settings
|
||||
import org.meshtastic.core.resources.app_functions_settings_summary
|
||||
import org.meshtastic.core.resources.bottom_nav_settings
|
||||
import org.meshtastic.core.resources.export_configuration
|
||||
import org.meshtastic.core.resources.filter_settings
|
||||
@@ -60,6 +64,7 @@ import org.meshtastic.core.ui.icon.FilterList
|
||||
import org.meshtastic.core.ui.icon.HelpOutline
|
||||
import org.meshtastic.core.ui.icon.List
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.SettingsRemote
|
||||
import org.meshtastic.core.ui.icon.Wifi
|
||||
import org.meshtastic.feature.settings.component.AppInfoSection
|
||||
import org.meshtastic.feature.settings.component.AppearanceSection
|
||||
@@ -87,6 +92,7 @@ fun SettingsScreen(
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
val appFunctionsAvailable: Boolean = koinInject(qualifier = named("googleServicesAvailable"))
|
||||
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
|
||||
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
@@ -226,7 +232,7 @@ fun SettingsScreen(
|
||||
// App-local settings are only relevant when configuring the local node
|
||||
if (state.isLocal) {
|
||||
PrivacySection(
|
||||
analyticsAvailable = state.analyticsAvailable,
|
||||
analyticsAvailable = appFunctionsAvailable,
|
||||
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(true).value,
|
||||
onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
|
||||
provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
|
||||
@@ -266,6 +272,18 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (appFunctionsAvailable) {
|
||||
ExpressiveSection(title = stringResource(Res.string.app_functions_settings)) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.app_functions_settings),
|
||||
supportingText = stringResource(Res.string.app_functions_settings_summary),
|
||||
leadingIcon = MeshtasticIcons.SettingsRemote,
|
||||
) {
|
||||
onNavigate(SettingsRoute.AppFunctionsSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistenceSection(
|
||||
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
|
||||
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings.appfunctions
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.app_functions_get_channel_info
|
||||
import org.meshtastic.core.resources.app_functions_get_device_status
|
||||
import org.meshtastic.core.resources.app_functions_get_mesh_metrics
|
||||
import org.meshtastic.core.resources.app_functions_get_mesh_status
|
||||
import org.meshtastic.core.resources.app_functions_get_node_details
|
||||
import org.meshtastic.core.resources.app_functions_get_node_list
|
||||
import org.meshtastic.core.resources.app_functions_get_recent_messages
|
||||
import org.meshtastic.core.resources.app_functions_get_unread_summary
|
||||
import org.meshtastic.core.resources.app_functions_master_summary
|
||||
import org.meshtastic.core.resources.app_functions_master_toggle
|
||||
import org.meshtastic.core.resources.app_functions_read_section
|
||||
import org.meshtastic.core.resources.app_functions_send_message
|
||||
import org.meshtastic.core.resources.app_functions_settings
|
||||
import org.meshtastic.core.resources.app_functions_write_section
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.SettingsRemote
|
||||
|
||||
@Composable
|
||||
fun AppFunctionsSettingsScreen(
|
||||
viewModel: AppFunctionsSettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val masterEnabled by viewModel.masterEnabled.collectAsStateWithLifecycle()
|
||||
val sendMessage by viewModel.sendMessageEnabled.collectAsStateWithLifecycle()
|
||||
val getMeshStatus by viewModel.getMeshStatusEnabled.collectAsStateWithLifecycle()
|
||||
val getNodeList by viewModel.getNodeListEnabled.collectAsStateWithLifecycle()
|
||||
val getChannelInfo by viewModel.getChannelInfoEnabled.collectAsStateWithLifecycle()
|
||||
val getDeviceStatus by viewModel.getDeviceStatusEnabled.collectAsStateWithLifecycle()
|
||||
val getNodeDetails by viewModel.getNodeDetailsEnabled.collectAsStateWithLifecycle()
|
||||
val getMeshMetrics by viewModel.getMeshMetricsEnabled.collectAsStateWithLifecycle()
|
||||
val getRecentMessages by viewModel.getRecentMessagesEnabled.collectAsStateWithLifecycle()
|
||||
val getUnreadSummary by viewModel.getUnreadSummaryEnabled.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.app_functions_settings),
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onBack,
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding).verticalScroll(rememberScrollState())) {
|
||||
MasterToggleSection(
|
||||
masterEnabled = masterEnabled,
|
||||
onToggle = { viewModel.setMasterEnabled(!masterEnabled) },
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
WriteFunctionsSection(
|
||||
masterEnabled = masterEnabled,
|
||||
sendMessage = sendMessage,
|
||||
onToggleSendMessage = { viewModel.setSendMessageEnabled(!sendMessage) },
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
ReadFunctionsSection(
|
||||
masterEnabled = masterEnabled,
|
||||
getMeshStatus = getMeshStatus,
|
||||
onToggleMeshStatus = { viewModel.setGetMeshStatusEnabled(!getMeshStatus) },
|
||||
getNodeList = getNodeList,
|
||||
onToggleNodeList = { viewModel.setGetNodeListEnabled(!getNodeList) },
|
||||
getChannelInfo = getChannelInfo,
|
||||
onToggleChannelInfo = { viewModel.setGetChannelInfoEnabled(!getChannelInfo) },
|
||||
getDeviceStatus = getDeviceStatus,
|
||||
onToggleDeviceStatus = { viewModel.setGetDeviceStatusEnabled(!getDeviceStatus) },
|
||||
getNodeDetails = getNodeDetails,
|
||||
onToggleNodeDetails = { viewModel.setGetNodeDetailsEnabled(!getNodeDetails) },
|
||||
getMeshMetrics = getMeshMetrics,
|
||||
onToggleMeshMetrics = { viewModel.setGetMeshMetricsEnabled(!getMeshMetrics) },
|
||||
getRecentMessages = getRecentMessages,
|
||||
onToggleRecentMessages = { viewModel.setGetRecentMessagesEnabled(!getRecentMessages) },
|
||||
getUnreadSummary = getUnreadSummary,
|
||||
onToggleUnreadSummary = { viewModel.setGetUnreadSummaryEnabled(!getUnreadSummary) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MasterToggleSection(masterEnabled: Boolean, onToggle: () -> Unit) {
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_master_toggle),
|
||||
checked = masterEnabled,
|
||||
leadingIcon = MeshtasticIcons.SettingsRemote,
|
||||
onClick = onToggle,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.app_functions_master_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WriteFunctionsSection(masterEnabled: Boolean, sendMessage: Boolean, onToggleSendMessage: () -> Unit) {
|
||||
Text(
|
||||
text = stringResource(Res.string.app_functions_write_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_send_message),
|
||||
checked = sendMessage,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleSendMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun ReadFunctionsSection(
|
||||
masterEnabled: Boolean,
|
||||
getMeshStatus: Boolean,
|
||||
onToggleMeshStatus: () -> Unit,
|
||||
getNodeList: Boolean,
|
||||
onToggleNodeList: () -> Unit,
|
||||
getChannelInfo: Boolean,
|
||||
onToggleChannelInfo: () -> Unit,
|
||||
getDeviceStatus: Boolean,
|
||||
onToggleDeviceStatus: () -> Unit,
|
||||
getNodeDetails: Boolean,
|
||||
onToggleNodeDetails: () -> Unit,
|
||||
getMeshMetrics: Boolean,
|
||||
onToggleMeshMetrics: () -> Unit,
|
||||
getRecentMessages: Boolean,
|
||||
onToggleRecentMessages: () -> Unit,
|
||||
getUnreadSummary: Boolean,
|
||||
onToggleUnreadSummary: () -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.app_functions_read_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_mesh_status),
|
||||
checked = getMeshStatus,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleMeshStatus,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_node_list),
|
||||
checked = getNodeList,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleNodeList,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_channel_info),
|
||||
checked = getChannelInfo,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleChannelInfo,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_device_status),
|
||||
checked = getDeviceStatus,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleDeviceStatus,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_node_details),
|
||||
checked = getNodeDetails,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleNodeDetails,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_mesh_metrics),
|
||||
checked = getMeshMetrics,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleMeshMetrics,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_recent_messages),
|
||||
checked = getRecentMessages,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleRecentMessages,
|
||||
)
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.app_functions_get_unread_summary),
|
||||
checked = getUnreadSummary,
|
||||
enabled = masterEnabled,
|
||||
onClick = onToggleUnreadSummary,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings.appfunctions
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.repository.AppFunctionsPrefs
|
||||
|
||||
@KoinViewModel
|
||||
class AppFunctionsSettingsViewModel(private val prefs: AppFunctionsPrefs) : ViewModel() {
|
||||
|
||||
val masterEnabled: StateFlow<Boolean> = prefs.masterEnabled
|
||||
val sendMessageEnabled: StateFlow<Boolean> = prefs.sendMessageEnabled
|
||||
val getMeshStatusEnabled: StateFlow<Boolean> = prefs.getMeshStatusEnabled
|
||||
val getNodeListEnabled: StateFlow<Boolean> = prefs.getNodeListEnabled
|
||||
val getChannelInfoEnabled: StateFlow<Boolean> = prefs.getChannelInfoEnabled
|
||||
val getDeviceStatusEnabled: StateFlow<Boolean> = prefs.getDeviceStatusEnabled
|
||||
val getNodeDetailsEnabled: StateFlow<Boolean> = prefs.getNodeDetailsEnabled
|
||||
val getMeshMetricsEnabled: StateFlow<Boolean> = prefs.getMeshMetricsEnabled
|
||||
val getRecentMessagesEnabled: StateFlow<Boolean> = prefs.getRecentMessagesEnabled
|
||||
val getUnreadSummaryEnabled: StateFlow<Boolean> = prefs.getUnreadSummaryEnabled
|
||||
|
||||
fun setMasterEnabled(enabled: Boolean) = prefs.setMasterEnabled(enabled)
|
||||
|
||||
fun setSendMessageEnabled(enabled: Boolean) = prefs.setSendMessageEnabled(enabled)
|
||||
|
||||
fun setGetMeshStatusEnabled(enabled: Boolean) = prefs.setGetMeshStatusEnabled(enabled)
|
||||
|
||||
fun setGetNodeListEnabled(enabled: Boolean) = prefs.setGetNodeListEnabled(enabled)
|
||||
|
||||
fun setGetChannelInfoEnabled(enabled: Boolean) = prefs.setGetChannelInfoEnabled(enabled)
|
||||
|
||||
fun setGetDeviceStatusEnabled(enabled: Boolean) = prefs.setGetDeviceStatusEnabled(enabled)
|
||||
|
||||
fun setGetNodeDetailsEnabled(enabled: Boolean) = prefs.setGetNodeDetailsEnabled(enabled)
|
||||
|
||||
fun setGetMeshMetricsEnabled(enabled: Boolean) = prefs.setGetMeshMetricsEnabled(enabled)
|
||||
|
||||
fun setGetRecentMessagesEnabled(enabled: Boolean) = prefs.setGetRecentMessagesEnabled(enabled)
|
||||
|
||||
fun setGetUnreadSummaryEnabled(enabled: Boolean) = prefs.setGetUnreadSummaryEnabled(enabled)
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import org.meshtastic.feature.settings.DeviceConfigurationScreen
|
||||
import org.meshtastic.feature.settings.ModuleConfigurationScreen
|
||||
import org.meshtastic.feature.settings.NodeListScreen
|
||||
import org.meshtastic.feature.settings.SettingsViewModel
|
||||
import org.meshtastic.feature.settings.appfunctions.AppFunctionsSettingsScreen
|
||||
import org.meshtastic.feature.settings.appfunctions.AppFunctionsSettingsViewModel
|
||||
import org.meshtastic.feature.settings.debugging.DebugScreen
|
||||
import org.meshtastic.feature.settings.debugging.DebugViewModel
|
||||
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
|
||||
@@ -248,6 +250,11 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
|
||||
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<SettingsRoute.AppFunctionsSettings> {
|
||||
val viewModel: AppFunctionsSettingsViewModel = koinViewModel()
|
||||
AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Expect declaration for the platform-specific settings main screen. */
|
||||
|
||||
@@ -5,6 +5,7 @@ xmlutil = "0.91.3"
|
||||
agp = "9.2.1"
|
||||
appcompat = "1.7.1"
|
||||
accompanist = "0.37.3"
|
||||
appfunctions = "1.0.0-alpha09"
|
||||
|
||||
# androidx
|
||||
datastore = "1.2.1"
|
||||
@@ -103,6 +104,9 @@ xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", v
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.13.0" }
|
||||
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.10.0" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||
androidx-appfunctions = { module = "androidx.appfunctions:appfunctions", version.ref = "appfunctions" }
|
||||
androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" }
|
||||
androidx-appfunctions-service = { module = "androidx.appfunctions:appfunctions-service", version.ref = "appfunctions" }
|
||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
|
||||
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
|
||||
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
|
||||
|
||||
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) |
|
||||
313
specs/20260521-091500-app-functions/spec.md
Normal file
313
specs/20260521-091500-app-functions/spec.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Feature Specification: Android App Functions Integration
|
||||
|
||||
**Feature Branch**: `jamesarich/crispy-barnacle`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Set up App Functions so our app can integrate better with system AI"
|
||||
**Cross-Platform Spec**: KMP — interfaces defined in commonMain; Android implementation in androidApp (Google flavor first-class)
|
||||
|
||||
## Summary
|
||||
|
||||
Expose key Meshtastic capabilities as [Android App Functions](https://developer.android.com/ai/appfunctions) so system AI assistants (Gemini, etc.) can discover and invoke them on behalf of the user. App Functions act as on-device MCP tools, letting users interact with the mesh network through natural language — sending messages and checking mesh health — without manually navigating the app UI.
|
||||
|
||||
**Phase 1 (this spec)** focuses on a minimal MVP of 2 functions (`sendMessage` + `getMeshStatus`) to validate the integration end-to-end. Additional functions (listNodes, getRecentMessages, getNodePosition, waypoints, traceroute) will be added in Phase 2 after validation.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Declare a minimal set of App Functions that validate the Meshtastic ↔ system AI integration pattern
|
||||
2. Enable natural-language interactions like "Send a message to the mesh" or "How many nodes are online?"
|
||||
3. Follow Android App Functions best practices: KDoc-described functions, `@AppFunctionSerializable` models, and proper `AppFunctionContext` usage
|
||||
4. Define platform-agnostic interfaces in `commonMain` so other platforms (Desktop, iOS) can expose equivalent capabilities through their own AI systems in the future
|
||||
5. Implement the Android-specific `@AppFunction` annotations in `androidApp` (Google flavor first-class)
|
||||
6. Integrate with existing Koin DI to resolve repositories and managers
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Implementing a remote MCP server (App Functions are on-device only)
|
||||
- Exposing radio configuration or admin operations to AI (security-sensitive; future consideration)
|
||||
- Building custom AI/LLM features within the app itself
|
||||
- Handling firmware updates or device provisioning through AI
|
||||
- Exposing raw protobuf operations or low-level radio commands
|
||||
- Phase 2+ functions (listNodes, getRecentMessages, getNodePosition, waypoints, traceroute) — deferred until Phase 1 validates the pattern
|
||||
- F-Droid flavor implementation (platform API works there, but Google flavor is first-class target)
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Send a Mesh Message via AI (Priority: P1)
|
||||
|
||||
As a user talking to my phone's AI assistant, I want to say "Send a message to the mesh saying I'll be at the trailhead in 30 minutes" so I can communicate without opening the app.
|
||||
|
||||
**Why this priority**: Messaging is the #1 use case for Meshtastic; it's the most natural AI-triggered action.
|
||||
|
||||
**Independent Test**: Can be verified by invoking the App Function through the test agent app and confirming the message appears in the mesh message list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the device is connected to a Meshtastic radio, **When** the AI assistant invokes `sendMessage` with text and a channel name, **Then** the channel is resolved via fuzzy matching, the message is transmitted over the mesh, and a confirmation with message ID is returned
|
||||
2. **Given** the device is NOT connected to a radio, **When** the AI invokes `sendMessage`, **Then** the function returns an error result indicating no active connection
|
||||
3. **Given** a valid node name is provided as the recipient, **When** the AI invokes `sendMessage` with a direct message target, **Then** the node name is fuzzy-matched and the message is sent as a DM to that specific node
|
||||
4. **Given** the AI invokes `sendMessage` more than 5 times within 60 seconds, **When** the rate limit is exceeded, **Then** `AppFunctionLimitExceededException` is thrown with a descriptive message
|
||||
5. **Given** a node name matches multiple nodes (e.g., "Jake" → "Jake's Radio" + "Jake_hiking"), **When** the match is ambiguous, **Then** `AppFunctionInvalidArgumentException` is thrown listing the candidate names for the AI to disambiguate
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Query Mesh Network Status (Priority: P1)
|
||||
|
||||
As a user, I want to ask my AI assistant "How's my mesh network doing?" and get a summary of online nodes, my node's battery, and connection state.
|
||||
|
||||
**Why this priority**: Status queries are read-only and safe — ideal first-class AI capabilities.
|
||||
|
||||
**Independent Test**: Can be verified by invoking `getMeshStatus` and confirming the returned data matches the app's node list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the device is connected, **When** the AI invokes `getMeshStatus`, **Then** it returns online node count, total node count, local battery level, and connection state
|
||||
2. **Given** the device is disconnected, **When** the AI invokes `getMeshStatus`, **Then** it returns the disconnected state with last-known node counts
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when multiple nodes match a name query? Return `AppFunctionInvalidArgumentException` listing candidate names so the AI agent can ask the user to clarify.
|
||||
- What happens when multiple channels match a name query? Same approach — return candidates for disambiguation.
|
||||
- What happens when the radio connection drops mid-operation? Return an error result; do not crash or hang.
|
||||
- What happens with very long messages? Enforce the Meshtastic message length limit (237 bytes for standard, longer for PKC) and return `AppFunctionInvalidArgumentException` if exceeded.
|
||||
- What happens if the rate limit is hit? Throw `AppFunctionLimitExceededException`; the AI agent handles this gracefully per platform conventions.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
| Component | Module / File | Purpose |
|
||||
|-----------|---------------|---------|
|
||||
| AiFunctionProvider (interface) | `core/data/src/commonMain/.../ai/AiFunctionProvider.kt` | Platform-agnostic contract defining operations exposable to AI systems |
|
||||
| MeshtasticAppFunctions | `androidApp/src/main/kotlin/.../appfunctions/MeshtasticAppFunctions.kt` | `@AppFunction`-annotated Android implementation |
|
||||
| AppFunctionModels | `androidApp/src/main/kotlin/.../appfunctions/models/` | `@AppFunctionSerializable` data classes for function inputs/outputs |
|
||||
| FuzzyNameResolver | `core/data/src/commonMain/.../ai/FuzzyNameResolver.kt` | Fuzzy matching for node and channel names (longest-substring, error if ambiguous) |
|
||||
| RateLimiter | `core/data/src/commonMain/.../ai/RateLimiter.kt` | Sliding-window rate limiter (5 calls / 60s) for send operations |
|
||||
| NodeRepository | `core/repository/` (commonMain) | Existing node data access — unchanged |
|
||||
| PacketRepository | `core/repository/` (commonMain) | Existing message data access — unchanged |
|
||||
| ServiceRepository | `core/repository/` (commonMain) | Existing connection state — unchanged |
|
||||
| CommandSender | `core/repository/` (commonMain) | Existing mesh command dispatch — unchanged |
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
System AI Agent (Gemini)
|
||||
↓ (EXECUTE_APP_FUNCTIONS permission)
|
||||
AppFunctionManager (Android OS, API 35+)
|
||||
↓
|
||||
MeshtasticAppFunctions (@AppFunction annotated, androidApp)
|
||||
↓
|
||||
AiFunctionProvider interface (commonMain contract)
|
||||
↓
|
||||
FuzzyNameResolver → NodeRepository / RadioConfigRepository (name → ID resolution)
|
||||
↓
|
||||
CommandSender / ServiceRepository (execute operation)
|
||||
↓
|
||||
MeshServiceOrchestrator → Radio
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[System AI / Gemini] -->|AppFunctionManager| B[MeshtasticAppFunctions]
|
||||
B --> C[AiFunctionProvider impl]
|
||||
C --> D[FuzzyNameResolver]
|
||||
C --> E[RateLimiter]
|
||||
D --> F[NodeRepository]
|
||||
D --> G[RadioConfigRepository]
|
||||
C --> H[ServiceRepository]
|
||||
C --> I[CommandSender]
|
||||
H --> J[MeshServiceOrchestrator]
|
||||
I --> J
|
||||
J --> K[RadioInterfaceService]
|
||||
```
|
||||
|
||||
### KMP Architecture Pattern
|
||||
|
||||
```
|
||||
commonMain/
|
||||
├── ai/
|
||||
│ ├── AiFunctionProvider.kt # Interface: what operations AI can invoke
|
||||
│ ├── AiFunctionResult.kt # Sealed result types (success/error)
|
||||
│ ├── FuzzyNameResolver.kt # Name matching logic (testable, shared)
|
||||
│ └── RateLimiter.kt # Token-bucket limiter (testable, shared)
|
||||
|
||||
androidApp/ (Google flavor)
|
||||
├── appfunctions/
|
||||
│ ├── MeshtasticAppFunctions.kt # @AppFunction declarations
|
||||
│ ├── AppFunctionFactory.kt # Koin-based factory for DI
|
||||
│ └── models/ # @AppFunctionSerializable types
|
||||
```
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The app MUST declare App Functions using the `@AppFunction(isDescribedByKDoc = true)` annotation with comprehensive KDoc descriptions
|
||||
- **FR-002**: All function parameters and return types MUST use `@AppFunctionSerializable` data classes with KDoc-described fields
|
||||
- **FR-003**: `sendMessage` MUST resolve the destination via fuzzy name matching (channel name or node name), transmit the text message, and return a confirmation with message ID
|
||||
- **FR-004**: `sendMessage` MUST enforce a rate limit of 5 invocations per 60-second sliding window, throwing `AppFunctionLimitExceededException` when exceeded
|
||||
- **FR-005**: `getMeshStatus` MUST return current connection state, online/total node counts, and local device battery level
|
||||
- **FR-006**: Fuzzy name matching MUST use longest-substring matching and throw `AppFunctionInvalidArgumentException` with candidate names when ambiguous
|
||||
- **FR-007**: All functions MUST gracefully handle the disconnected state by throwing appropriate `AppFunctionException` subclasses (not generic exceptions)
|
||||
- **FR-008**: The `sendMessage` function MUST validate message length against the Meshtastic protocol limit before transmission
|
||||
- **FR-009**: A platform-agnostic `AiFunctionProvider` interface MUST be defined in `commonMain` with the operation contracts
|
||||
- **FR-010**: The Android implementation MUST resolve dependencies through Koin via a custom `AppFunctionConfiguration.Provider`
|
||||
- **FR-011**: `sendMessage` MUST send immediately without a confirmation dialog (AI invocation implies user intent per platform guidelines)
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: App Functions MUST NOT expose sensitive configuration (admin channels, encryption keys, radio settings) to AI agents
|
||||
- **NFR-002**: All App Function operations MUST complete within 5 seconds or throw a timeout error
|
||||
- **NFR-003**: The App Functions layer requires `compileSdk` 36+ and MUST only be active on devices running Android 16 (API 35+)
|
||||
- **NFR-004**: KDoc descriptions MUST be clear enough for an AI agent to understand the function's purpose without additional context
|
||||
- **NFR-005**: The `AiFunctionProvider` interface in commonMain MUST have no Android dependencies
|
||||
- **NFR-006**: Dependencies: `androidx.appfunctions:appfunctions:1.0.0-alpha09`, `appfunctions-service:1.0.0-alpha09`, `appfunctions-compiler:1.0.0-alpha09` (KSP)
|
||||
|
||||
## Source-Set Impact
|
||||
|
||||
| Source Set | Impact | Justification |
|
||||
|-----------|--------|---------------|
|
||||
| `commonMain` (core/data) | New: `AiFunctionProvider` interface, `FuzzyNameResolver`, `RateLimiter`, result types | Platform-agnostic contracts and shared logic |
|
||||
| `androidMain` (androidApp, Google flavor) | New: `@AppFunction` declarations, serializable models, Koin factory | AppFunctions is an Android platform API |
|
||||
| `jvmMain` | None | Desktop not affected (future: could implement via local MCP server) |
|
||||
| `iosMain` | None | iOS not affected (future: could implement via App Intents) |
|
||||
|
||||
## Design Standards Compliance
|
||||
|
||||
- [x] New screens reviewed against design standards — N/A, no UI changes
|
||||
- [x] M3 component selection verified — N/A, no UI components
|
||||
- [x] Accessibility: N/A — App Functions are invoked by AI, not direct user interaction
|
||||
- [x] Typography: N/A
|
||||
- [x] KDoc documentation provides clear natural-language descriptions for AI discovery
|
||||
|
||||
## Privacy Assessment
|
||||
|
||||
- [x] No PII logged or exposed beyond what the user has already shared on the mesh
|
||||
- [x] Position data gated behind existing privacy settings
|
||||
- [x] Messages sent only with user's implicit consent (AI assistant invocation = user intent)
|
||||
- [x] No encryption keys, admin channel configs, or radio settings exposed
|
||||
- [x] Proto submodule (`core/proto`) not modified (read-only upstream)
|
||||
- [x] `EXECUTE_APP_FUNCTIONS` permission required by callers — only authorized system agents can invoke
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Both declared App Functions are discoverable via `adb shell cmd app_function list-app-functions | grep org.meshtastic` on API 35+ devices
|
||||
- **SC-002**: The test agent app can successfully invoke `sendMessage` and receive a confirmation with message ID when connected
|
||||
- **SC-003**: The test agent app can invoke `getMeshStatus` and receive accurate node counts matching the app's UI within 1 second
|
||||
- **SC-004**: `sendMessage` returns `AppFunctionLimitExceededException` after 5 rapid invocations within 60 seconds
|
||||
- **SC-005**: Ambiguous name queries return `AppFunctionInvalidArgumentException` with candidate list
|
||||
- **SC-006**: Zero crashes or ANRs introduced by App Function invocations
|
||||
|
||||
## Decisions Log
|
||||
|
||||
| # | Question | Decision | Rationale |
|
||||
|---|----------|----------|-----------|
|
||||
| 1 | User confirmation before sending? | No — send immediately | AI invocation implies user intent per platform guidelines; messaging is additive not destructive |
|
||||
| 2 | Rate limiting? | Yes — 5 messages/60s sliding window | Aligns with radio duty-cycle constraints; platform provides `AppFunctionLimitExceededException` |
|
||||
| 3 | Channel/node selection? | Fuzzy name matching | More natural for AI conversation; longest-substring match with error on ambiguity |
|
||||
| 4 | Build flavor scope? | Google flavor first-class, KMP interfaces in commonMain | Platform API works everywhere but Gemini (primary caller) is Google; KMP future-proofs |
|
||||
| 5 | Initial scope? | Minimal MVP (2 functions) | Validate integration pattern before expanding; sendMessage + getMeshStatus |
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The user has already paired and connected to a Meshtastic radio device
|
||||
- The app is installed on an Android 16+ (API 35) device that supports App Functions
|
||||
- System AI agents have the `EXECUTE_APP_FUNCTIONS` permission (granted by the OS)
|
||||
- The Jetpack AppFunctions library (`androidx.appfunctions:appfunctions-*` 1.0.0-alpha09) is stable enough for integration
|
||||
- Koin dependency injection context is available when App Functions are invoked (app process is alive)
|
||||
- The AppFunctions annotation processor (KSP) is compatible with the project's existing KSP setup
|
||||
- `compileSdk` is already 37 (satisfies the ≥36 requirement for AppFunctions library)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Exact rate limit values**: Is 5 messages/60 seconds the right threshold, or should it align with a specific radio duty-cycle calculation?
|
||||
2. **Background invocation**: Can App Functions be invoked when the app is in the background but the service is running? (Likely yes, since `AppFunctionService` runs in the app process)
|
||||
|
||||
## Future Considerations (Phase 3+)
|
||||
|
||||
- **getNodePosition**: "Where is Jake?" → return GPS coordinates (gated by privacy settings)
|
||||
- **Waypoint management**: Create/delete waypoints via AI
|
||||
- **Traceroute**: "Can I reach node X?" → invoke traceroute and return hop count
|
||||
- **Location sharing**: "Share my location on the mesh" → trigger position broadcast
|
||||
- **requestTraceroute**: Non-destructive route diagnostic via fuzzy node name
|
||||
- **sendQuickChat**: Voice-triggered pre-configured message shortcuts
|
||||
- **findNearbyNodes**: Location-aware proximity query sorted by distance
|
||||
- **requestNodePosition**: Ask a specific node to share its GPS coordinates
|
||||
- **Desktop/iOS parity**: Implement `AiFunctionProvider` via local MCP server (Desktop) or App Intents (iOS)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Message History Functions
|
||||
|
||||
### User Story 6 - Read Recent Messages via AI (Priority: P1)
|
||||
|
||||
As a user returning from an activity, I want to ask "What messages did I miss?" and get a summary of recent mesh messages without opening the app.
|
||||
|
||||
**Why this priority**: "Catch me up" is the #1 voice query pattern for communication apps. Mesh messages arrive asynchronously during hikes/outdoor activities where the phone is pocketed.
|
||||
|
||||
**Independent Test**: Invoke `getRecentMessages` and confirm returned messages match the app's message list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the device is connected and messages exist, **When** the AI invokes `getRecentMessages` without filters, **Then** the most recent messages (up to limit) are returned with sender name, text, channel, and timestamp
|
||||
2. **Given** a contactName is provided, **When** the AI invokes `getRecentMessages` with that filter, **Then** only messages from that contact/channel are returned
|
||||
3. **Given** no messages exist, **When** the AI invokes `getRecentMessages`, **Then** an empty list is returned (not an error)
|
||||
4. **Given** the device is disconnected, **When** the AI invokes `getRecentMessages`, **Then** cached messages are still returned (message history is local)
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 - Unread Message Summary via AI (Priority: P1)
|
||||
|
||||
As a user, I want to ask "Do I have unread messages?" and get a per-contact breakdown showing who messaged me and a preview of their last message.
|
||||
|
||||
**Why this priority**: Unread summaries let users decide whether to open the app, reducing unnecessary screen time during outdoor activities.
|
||||
|
||||
**Independent Test**: Invoke `getUnreadSummary` and confirm counts match the app's contact list badges.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** unread messages exist from multiple contacts, **When** the AI invokes `getUnreadSummary`, **Then** the total unread count and per-contact breakdown (name, unread count, last message preview) are returned
|
||||
2. **Given** no unread messages exist, **When** the AI invokes `getUnreadSummary`, **Then** totalUnreadCount is 0 and the contacts list is empty
|
||||
3. **Given** a contact has been muted, **When** the AI invokes `getUnreadSummary`, **Then** muted contacts are excluded from the breakdown
|
||||
|
||||
---
|
||||
|
||||
### Functional Requirements (Phase 3)
|
||||
|
||||
- **FR-012**: `getRecentMessages` MUST return recent messages sorted newest-first, limited to a configurable count (default 20, max 50)
|
||||
- **FR-013**: `getRecentMessages` MUST support optional `contactName` filter using the existing `FuzzyNameResolver`
|
||||
- **FR-014**: `getRecentMessages` MUST NOT require an active radio connection (messages are cached locally)
|
||||
- **FR-015**: `getUnreadSummary` MUST return total unread count and per-contact breakdown with last message preview
|
||||
- **FR-016**: `getUnreadSummary` MUST exclude muted contacts from the breakdown
|
||||
- **FR-017**: Both functions MUST respect the 5-second timeout constraint (NFR-002)
|
||||
|
||||
### Architecture Addition
|
||||
|
||||
```
|
||||
commonMain/ai/
|
||||
├── AiFunctionProvider.kt # + getRecentMessages(), getUnreadSummary()
|
||||
├── AiFunctionResult.kt # + GetRecentMessagesResult, GetUnreadSummaryResult
|
||||
|
||||
androidApp/ (Google flavor)
|
||||
├── appfunctions/
|
||||
│ ├── MeshtasticAppFunctions.kt # + getRecentMessages, getUnreadSummary
|
||||
│ └── AppFunctionModels.kt # + MessageInfo, UnreadSummaryResponse, ContactUnreadInfo
|
||||
```
|
||||
|
||||
### Data Flow (Message History)
|
||||
|
||||
```
|
||||
AiFunctionProvider.getRecentMessages()
|
||||
↓
|
||||
PacketRepository.getMessagesFrom() / getContacts()
|
||||
↓
|
||||
NodeRepository (resolve sender names)
|
||||
↓
|
||||
Return MessageInfo list
|
||||
```
|
||||
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