diff --git a/agent/app/build.gradle.kts b/agent/app/build.gradle.kts
index 3a63ee6..d31e3a6 100644
--- a/agent/app/build.gradle.kts
+++ b/agent/app/build.gradle.kts
@@ -16,6 +16,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.screenshot)
@@ -60,11 +61,13 @@ android {
dimension = "mode"
buildConfigField("Boolean", "IS_RETAIL", "true")
- val containsRetail = gradle.startParameter.taskNames.any { it.contains("Retail", ignoreCase = true) }
+ val containsRetail = gradle.startParameter.taskNames.any {
+ it.contains("Retail", ignoreCase = true)
+ }
val apiKey = project.findProperty("GEMINI_API_KEY") as? String ?: ""
if (containsRetail && apiKey.isEmpty()) {
throw GradleException(
- "GEMINI_API_KEY project property is required for retail builds. Pass it using -PGEMINI_API_KEY=your_key",
+ "GEMINI_API_KEY project property is required for retail builds. Pass it using -PGEMINI_API_KEY=your_key"
)
}
buildConfigField("String", "GEMINI_API_KEY", "\"$apiKey\"")
diff --git a/agent/app/src/main/AndroidManifest.xml b/agent/app/src/main/AndroidManifest.xml
index 8c4a97e..ea05688 100644
--- a/agent/app/src/main/AndroidManifest.xml
+++ b/agent/app/src/main/AndroidManifest.xml
@@ -57,6 +57,16 @@
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar"
android:exported="false"
tools:replace="android:label,android:theme" />
+
+
+
+
+ os.write(requestJson.toString().toByteArray(Charsets.UTF_8))
+ }
+
+ val responseCode = connection.responseCode
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ val errorBody =
+ connection.errorStream?.bufferedReader()?.use { it.readText() }
+ ?: "HTTP $responseCode"
+ throw IllegalStateException(
+ "Image generation failed ($responseCode): $errorBody"
+ )
+ }
+
+ val responseText = connection.inputStream.bufferedReader().use { it.readText() }
+ val responseJson = JSONObject(responseText)
+ val candidates = responseJson.optJSONArray("candidates")
+ if (candidates == null || candidates.length() == 0) {
+ throw IllegalStateException(
+ "No candidates returned from Gemini image generation API"
+ )
+ }
+
+ val parts =
+ candidates
+ .getJSONObject(0)
+ .optJSONObject("content")
+ ?.optJSONArray("parts")
+ if (parts == null || parts.length() == 0) {
+ throw IllegalStateException(
+ "No parts returned in candidate content. Gemini response: $responseText"
+ )
+ }
+
+ var base64Data: String? = null
+ var mimeType = "image/png"
+ for (i in 0 until parts.length()) {
+ val part = parts.getJSONObject(i)
+ val inlineData =
+ part.optJSONObject("inlineData")
+ ?: part.optJSONObject("inline_data")
+ if (inlineData != null) {
+ base64Data = inlineData.optString("data")
+ val returnedMime =
+ inlineData
+ .optString("mimeType")
+ .takeIf { it.isNotBlank() }
+ ?: inlineData.optString("mime_type")
+ if (returnedMime.isNotBlank()) {
+ mimeType = returnedMime
+ }
+ break
+ }
+ }
+
+ if (base64Data.isNullOrBlank()) {
+ throw IllegalStateException(
+ "No inlineData image found in response parts. Gemini response: $responseText"
+ )
+ }
+
+ val imageBytes = Base64.decode(base64Data, Base64.DEFAULT)
+ val extension =
+ if (mimeType.contains("jpeg") || mimeType.contains("jpg")) "jpg" else "png"
+ val cachedFile =
+ File(
+ context.cacheDir,
+ "generated_${UUID.randomUUID()}.$extension"
+ )
+ cachedFile.writeBytes(imageBytes)
+
+ val authority = "${context.packageName}.fileprovider"
+ val contentUri = FileProvider.getUriForFile(context, authority, cachedFile)
+ GeneratedImageResult(
+ imageUri = contentUri.toString(),
+ mimeType = mimeType,
+ prompt = prompt
+ )
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ /** Represents the result of an image generation request. */
+ @AppFunctionSerializable
+ data class GeneratedImageResult(
+ /** The remote URI or URL of the generated image. */
+ val imageUri: String,
+ /** The MIME type of the generated image. */
+ val mimeType: String,
+ /** The original prompt used to generate the image. */
+ val prompt: String
)
}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/GeminiProviderImpl.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/GeminiProviderImpl.kt
index 7e2bd18..e061216 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/data/GeminiProviderImpl.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/GeminiProviderImpl.kt
@@ -29,6 +29,9 @@ import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
+import java.time.LocalDate
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@@ -39,216 +42,220 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
-import java.time.LocalDate
-import javax.inject.Inject
-import javax.inject.Singleton
@Singleton
class GeminiProviderImpl
- @Inject
- constructor(
- private val httpClient: HttpClient,
- private val toolConverter: GeminiToolConverter,
- ) : LlmProvider {
- override suspend fun generateResponse(
- previousInteractionId: String?,
- input: LlmInput,
- tools: List,
- apiKey: String,
- modelName: String,
- ): LlmResponse {
- val convertedTools =
- tools.mapNotNull { tool ->
- try {
- buildJsonObject {
- put(KEY_TYPE, JsonPrimitive(VALUE_FUNCTION))
- val functionSchema = toolConverter.convert(tool)
- functionSchema.forEach { (key, value) -> put(key, value) }
- }
- } catch (e: IllegalArgumentException) {
- Log.e(TAG, "Failed to convert tool ${tool.id}: ${e.message}", e)
- null
+@Inject
+constructor(
+ private val httpClient: HttpClient,
+ private val toolConverter: GeminiToolConverter
+) : LlmProvider {
+ override suspend fun generateResponse(
+ previousInteractionId: String?,
+ input: LlmInput,
+ tools: List,
+ apiKey: String,
+ modelName: String
+ ): LlmResponse {
+ val convertedTools =
+ tools.mapNotNull { tool ->
+ try {
+ buildJsonObject {
+ put(KEY_TYPE, JsonPrimitive(VALUE_FUNCTION))
+ val functionSchema = toolConverter.convert(tool)
+ functionSchema.forEach { (key, value) -> put(key, value) }
}
+ } catch (e: IllegalArgumentException) {
+ Log.e(TAG, "Failed to convert tool ${tool.id}: ${e.message}", e)
+ null
}
+ }
- val requestBody =
- buildJsonObject {
- put(KEY_MODEL, JsonPrimitive(modelName))
-
- put(KEY_SYSTEM_INSTRUCTION, JsonPrimitive(getSystemInstruction()))
-
- when (input) {
- is LlmInput.ToolResponse -> {
- val inputElement =
- kotlinx.serialization.json.buildJsonArray {
- input.outputs.forEach { output ->
- val matchingTool = tools.find { it.id == output.functionId }
- val mappedName =
- if (matchingTool != null) {
- toolConverter.getToolName(matchingTool)
- } else {
- output.functionId
- }
+ val requestBody =
+ buildJsonObject {
+ put(KEY_MODEL, JsonPrimitive(modelName))
- add(
- buildJsonObject {
- put("type", "function_result")
- put("name", mappedName)
- if (output.callId.isNotEmpty()) {
- put("call_id", output.callId)
- }
- put("result", output.result)
- },
- )
- }
- }
- put(KEY_INPUT, inputElement)
- }
- is LlmInput.UserMessage -> {
- put(KEY_INPUT, JsonPrimitive(input.text))
- }
- }
+ put(KEY_SYSTEM_INSTRUCTION, JsonPrimitive(getSystemInstruction()))
- if (convertedTools.isNotEmpty()) {
- put(KEY_TOOLS, kotlinx.serialization.json.JsonArray(convertedTools))
- }
+ when (input) {
+ is LlmInput.ToolResponse -> {
+ val inputElement =
+ kotlinx.serialization.json.buildJsonArray {
+ input.outputs.forEach { output ->
+ val matchingTool = tools.find { it.id == output.functionId }
+ val mappedName =
+ if (matchingTool != null) {
+ toolConverter.getToolName(matchingTool)
+ } else {
+ output.functionId
+ }
- if (previousInteractionId != null) {
- put(KEY_PREVIOUS_INTERACTION_ID, JsonPrimitive(previousInteractionId))
+ add(
+ buildJsonObject {
+ put("type", "function_result")
+ put("name", mappedName)
+ if (output.callId.isNotEmpty()) {
+ put("call_id", output.callId)
+ }
+ put("result", output.result)
+ }
+ )
+ }
+ }
+ put(KEY_INPUT, inputElement)
+ }
+ is LlmInput.UserMessage -> {
+ put(KEY_INPUT, JsonPrimitive(input.text))
}
}
- Log.d(TAG, "Gemini Request Body: $requestBody")
+ if (convertedTools.isNotEmpty()) {
+ put(KEY_TOOLS, kotlinx.serialization.json.JsonArray(convertedTools))
+ }
- val response: HttpResponse =
- try {
- httpClient.post(GEMINI_INTERACTIONS_URL) {
- contentType(ContentType.Application.Json)
- header(HEADER_API_KEY, apiKey)
- header(HEADER_API_REVISION, VALUE_API_REVISION)
- setBody(requestBody)
- }
- } catch (e: Exception) {
- return LlmResponse.Error("Network error: ${e.message}")
+ if (previousInteractionId != null) {
+ put(KEY_PREVIOUS_INTERACTION_ID, JsonPrimitive(previousInteractionId))
}
+ }
- val responseBodyText = response.bodyAsText()
- Log.d(TAG, "Gemini Response Body: $responseBodyText")
+ Log.d(TAG, "Gemini Request Body: $requestBody")
- if (response.status.value !in 200..299) {
- return LlmResponse.Error("Gemini API error: ${response.status} - $responseBodyText")
+ val response: HttpResponse =
+ try {
+ httpClient.post(GEMINI_INTERACTIONS_URL) {
+ contentType(ContentType.Application.Json)
+ header(HEADER_API_KEY, apiKey)
+ header(HEADER_API_REVISION, VALUE_API_REVISION)
+ setBody(requestBody)
+ }
+ } catch (e: Exception) {
+ return LlmResponse.Error("Network error: ${e.message}")
}
- val jsonResponse = Json.parseToJsonElement(responseBodyText).jsonObject
-
- val newInteractionId =
- jsonResponse[KEY_ID]?.jsonPrimitive?.content
- ?: return LlmResponse.Error("Missing interaction ID in response")
- val steps = jsonResponse[KEY_STEPS]?.jsonArray
-
- val parts = mutableListOf()
-
- if (steps != null) {
- for (step in steps) {
- val stepObj = step.jsonObject
- val stepType = stepObj[KEY_TYPE]?.jsonPrimitive?.content
-
- if (stepType == VALUE_MODEL_OUTPUT) {
- val content = stepObj[KEY_CONTENT]?.jsonArray
- if (content != null) {
- for (part in content) {
- val partObj = part.jsonObject
- val partType = partObj[KEY_TYPE]?.jsonPrimitive?.content
- if (partType == VALUE_TEXT) {
- val text = partObj[KEY_TEXT]?.jsonPrimitive?.content
- if (text != null) {
- parts.add(LlmResponsePart.Text(text))
- }
+ val responseBodyText = response.bodyAsText()
+ Log.d(TAG, "Gemini Response Body: $responseBodyText")
+
+ if (response.status.value !in 200..299) {
+ return LlmResponse.Error("Gemini API error: ${response.status} - $responseBodyText")
+ }
+
+ val jsonResponse = Json.parseToJsonElement(responseBodyText).jsonObject
+
+ val newInteractionId =
+ jsonResponse[KEY_ID]?.jsonPrimitive?.content
+ ?: return LlmResponse.Error("Missing interaction ID in response")
+ val steps = jsonResponse[KEY_STEPS]?.jsonArray
+
+ val parts = mutableListOf()
+
+ if (steps != null) {
+ for (step in steps) {
+ val stepObj = step.jsonObject
+ val stepType = stepObj[KEY_TYPE]?.jsonPrimitive?.content
+
+ if (stepType == VALUE_MODEL_OUTPUT) {
+ val content = stepObj[KEY_CONTENT]?.jsonArray
+ if (content != null) {
+ for (part in content) {
+ val partObj = part.jsonObject
+ val partType = partObj[KEY_TYPE]?.jsonPrimitive?.content
+ if (partType == VALUE_TEXT) {
+ val text = partObj[KEY_TEXT]?.jsonPrimitive?.content
+ if (text != null) {
+ parts.add(LlmResponsePart.Text(text))
}
}
}
- } else if (stepType == VALUE_FUNCTION_CALL) {
- val name =
- stepObj[KEY_NAME]?.jsonPrimitive?.content
- ?: return LlmResponse.Error("Called function without a name")
- val matchingTool =
- tools.find { tool -> toolConverter.getToolName(tool) == name }
- ?: return LlmResponse.Error("Called unknown function: $name")
-
- val packageName = matchingTool.packageName
- val functionId = matchingTool.id
-
- val args = stepObj[KEY_ARGUMENTS]?.jsonObject
- val argumentsMap = mutableMapOf()
- if (args != null) {
- for ((key, value) in args) {
- argumentsMap[key] = value.toPrimitive()
- }
+ }
+ } else if (stepType == VALUE_FUNCTION_CALL) {
+ val name =
+ stepObj[KEY_NAME]?.jsonPrimitive?.content
+ ?: return LlmResponse.Error("Called function without a name")
+ val matchingTool =
+ tools.find { tool -> toolConverter.getToolName(tool) == name }
+ ?: return LlmResponse.Error("Called unknown function: $name")
+
+ val packageName = matchingTool.packageName
+ val functionId = matchingTool.id
+
+ val args = stepObj[KEY_ARGUMENTS]?.jsonObject
+ val argumentsMap = mutableMapOf()
+ if (args != null) {
+ for ((key, value) in args) {
+ argumentsMap[key] = value.toPrimitive()
}
- val callId =
- stepObj["id"]?.jsonPrimitive?.content
- ?: return LlmResponse.Error("Function call missing call_id in response")
- parts.add(
- LlmResponsePart.ToolCall(
- packageName = packageName,
- functionId = functionId,
- arguments = argumentsMap,
- callId = callId,
- ),
- )
- } else {
- Log.d(TAG, "Unsupported step type: $stepType")
}
+ val callId =
+ stepObj["id"]?.jsonPrimitive?.content
+ ?: return LlmResponse.Error(
+ "Function call missing call_id in response"
+ )
+ parts.add(
+ LlmResponsePart.ToolCall(
+ packageName = packageName,
+ functionId = functionId,
+ arguments = argumentsMap,
+ callId = callId
+ )
+ )
+ } else {
+ Log.d(TAG, "Unsupported step type: $stepType")
}
}
-
- return LlmResponse.Success(
- interactionId = newInteractionId,
- parts = parts,
- )
}
- private fun JsonElement.toPrimitive(): Any? {
- return when (this) {
- is JsonPrimitive -> {
- if (this.isString) return this.content
- return this.content.toBooleanStrictOrNull()
- ?: this.content.toLongOrNull()
- ?: this.content.toDoubleOrNull()
- }
- is JsonObject -> this.mapValues { it.value.toPrimitive() }
- is JsonArray -> this.map { it.toPrimitive() }
+ return LlmResponse.Success(
+ interactionId = newInteractionId,
+ parts = parts
+ )
+ }
+
+ private fun JsonElement.toPrimitive(): Any? {
+ return when (this) {
+ is JsonPrimitive -> {
+ if (this.isString) return this.content
+ return this.content.toBooleanStrictOrNull()
+ ?: this.content.toLongOrNull()
+ ?: this.content.toDoubleOrNull()
}
+ is JsonObject -> this.mapValues { it.value.toPrimitive() }
+ is JsonArray -> this.map { it.toPrimitive() }
}
+ }
- private fun getSystemInstruction(): String {
- val currentDate = LocalDate.now().toString()
- return "You are an assistant running on Android. Be concise, direct and helpful. Today's date is $currentDate."
- }
+ private fun getSystemInstruction(): String {
+ val currentDate = LocalDate.now().toString()
+ return """
+ You are an AI assistant running on Android. Today's date is $currentDate.
+ Always reply to the user using concise, friendly, natural human language. Never respond to the user with raw JSON or structured code blocks unless explicitly asked to write code.
+ When a user asks you to generate an image, call the generateImage tool. After generateImage completes, confirm to the user in a natural sentence (for example: "I have generated that image for you.").
+ When a user asks you to generate an image and use it in an app (for example, setting a chat wallpaper or attaching an image to a note), first call generateImage, then call the target app function passing the returned imageUri.
+ """.trimIndent()
+ }
- companion object {
- private const val GEMINI_INTERACTIONS_URL =
- "https://generativelanguage.googleapis.com/v1beta/interactions"
- private const val TAG = "GeminiProvider"
-
- private const val KEY_MODEL = "model"
- private const val KEY_INPUT = "input"
- private const val KEY_TOOLS = "tools"
- private const val KEY_TYPE = "type"
- private const val VALUE_FUNCTION = "function"
- private const val KEY_PREVIOUS_INTERACTION_ID = "previous_interaction_id"
- private const val KEY_SYSTEM_INSTRUCTION = "system_instruction"
- private const val HEADER_API_KEY = "x-goog-api-key"
- private const val KEY_ID = "id"
- private const val KEY_STEPS = "steps"
- private const val KEY_CONTENT = "content"
- private const val VALUE_MODEL_OUTPUT = "model_output"
- private const val VALUE_TEXT = "text"
- private const val KEY_TEXT = "text"
- private const val VALUE_FUNCTION_CALL = "function_call"
- private const val KEY_NAME = "name"
- private const val KEY_ARGUMENTS = "arguments"
- private const val HEADER_API_REVISION = "Api-Revision"
- private const val VALUE_API_REVISION = "2026-05-20"
- }
+ companion object {
+ private const val GEMINI_INTERACTIONS_URL =
+ "https://generativelanguage.googleapis.com/v1beta/interactions"
+ private const val TAG = "GeminiProvider"
+
+ private const val KEY_MODEL = "model"
+ private const val KEY_INPUT = "input"
+ private const val KEY_TOOLS = "tools"
+ private const val KEY_TYPE = "type"
+ private const val VALUE_FUNCTION = "function"
+ private const val KEY_PREVIOUS_INTERACTION_ID = "previous_interaction_id"
+ private const val KEY_SYSTEM_INSTRUCTION = "system_instruction"
+ private const val HEADER_API_KEY = "x-goog-api-key"
+ private const val KEY_ID = "id"
+ private const val KEY_STEPS = "steps"
+ private const val KEY_CONTENT = "content"
+ private const val VALUE_MODEL_OUTPUT = "model_output"
+ private const val VALUE_TEXT = "text"
+ private const val KEY_TEXT = "text"
+ private const val VALUE_FUNCTION_CALL = "function_call"
+ private const val KEY_NAME = "name"
+ private const val KEY_ARGUMENTS = "arguments"
+ private const val HEADER_API_REVISION = "Api-Revision"
+ private const val VALUE_API_REVISION = "2026-05-20"
}
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/db/AppDatabase.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/db/AppDatabase.kt
index ae383e6..46d31c1 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/data/db/AppDatabase.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/db/AppDatabase.kt
@@ -17,11 +17,14 @@ package com.example.appfunctions.agent.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
import com.example.appfunctions.agent.data.db.dao.ChatDao
+import com.example.appfunctions.agent.data.db.entities.MessageAttachmentConverter
import com.example.appfunctions.agent.data.db.entities.MessageEntity
import com.example.appfunctions.agent.data.db.entities.ThreadEntity
-@Database(entities = [ThreadEntity::class, MessageEntity::class], version = 2, exportSchema = false)
+@Database(entities = [ThreadEntity::class, MessageEntity::class], version = 3, exportSchema = false)
+@TypeConverters(MessageAttachmentConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/db/entities/MessageEntity.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/db/entities/MessageEntity.kt
index 075700b..7144b03 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/data/db/entities/MessageEntity.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/db/entities/MessageEntity.kt
@@ -19,19 +19,22 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
@Entity(
tableName = "messages",
foreignKeys =
- [
- ForeignKey(
- entity = ThreadEntity::class,
- parentColumns = ["threadId"],
- childColumns = ["threadId"],
- onDelete = ForeignKey.CASCADE,
- ),
- ],
- indices = [Index("threadId")],
+ [
+ ForeignKey(
+ entity = ThreadEntity::class,
+ parentColumns = ["threadId"],
+ childColumns = ["threadId"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [Index("threadId")]
)
data class MessageEntity(
@PrimaryKey val messageId: String,
@@ -46,15 +49,42 @@ data class MessageEntity(
*/
val pendingIntentId: String? = null,
val targetPackageName: String? = null,
+ val attachments: List = emptyList()
)
+@Serializable
+data class MessageAttachment(
+ val uri: String,
+ val mimeType: String
+)
+
+class MessageAttachmentConverter {
+ private val dbJson = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ @androidx.room.TypeConverter
+ fun fromAttachments(attachments: List): String =
+ dbJson.encodeToString(attachments)
+
+ @androidx.room.TypeConverter
+ fun toAttachments(jsonString: String?): List =
+ if (jsonString.isNullOrBlank()) {
+ emptyList()
+ } else {
+ runCatching { dbJson.decodeFromString>(jsonString) }
+ .getOrDefault(emptyList())
+ }
+}
+
enum class MessageRole {
USER,
- ASSISTANT,
+ ASSISTANT
}
enum class MessageProcessingStatus {
PENDING_AGENT_RESPONSE,
PROCESSED,
- FAILED,
+ FAILED
}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/di/DataModule.kt b/agent/app/src/main/java/com/example/appfunctions/agent/di/DataModule.kt
index 78ddcc7..bdc1410 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/di/DataModule.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/di/DataModule.kt
@@ -39,10 +39,10 @@ import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
-import kotlinx.serialization.json.Json
import javax.inject.Singleton
+import kotlinx.serialization.json.Json
-private val Context.settingsDataStore: DataStore by
+val Context.settingsDataStore: DataStore by
preferencesDataStore(name = "settings")
@Module
@@ -58,32 +58,30 @@ abstract class DataModule {
@Binds
@Singleton
- abstract fun bindPendingIntentRepository(impl: InMemoryPendingIntentRepository): PendingIntentRepository
+ abstract fun bindPendingIntentRepository(
+ impl: InMemoryPendingIntentRepository
+ ): PendingIntentRepository
@Binds
@Singleton
abstract fun bindLlmProviderFactory(
- impl: com.example.appfunctions.agent.data.LlmProviderFactoryImpl,
+ impl: com.example.appfunctions.agent.data.LlmProviderFactoryImpl
): com.example.appfunctions.agent.domain.LlmProviderFactory
companion object {
@Provides
@Singleton
- fun provideDataStore(
- @ApplicationContext context: Context,
- ): DataStore {
+ fun provideDataStore(@ApplicationContext context: Context): DataStore {
return context.settingsDataStore
}
@Provides
@Singleton
- fun provideAppDatabase(
- @ApplicationContext context: Context,
- ): AppDatabase {
+ fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
- "app_database",
+ "app_database"
)
.fallbackToDestructiveMigration()
.build()
@@ -105,7 +103,7 @@ abstract class DataModule {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
- },
+ }
)
}
install(HttpTimeout) { socketTimeoutMillis = 30000 }
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/AgentOrchestrator.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/AgentOrchestrator.kt
index b48931d..92dc4ce 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/domain/AgentOrchestrator.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/AgentOrchestrator.kt
@@ -15,10 +15,14 @@
*/
package com.example.appfunctions.agent.domain
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
import android.util.Log
import androidx.appfunctions.metadata.AppFunctionMetadata
import com.example.appfunctions.agent.data.LlmProviderName
import com.example.appfunctions.agent.data.SettingsRepository
+import com.example.appfunctions.agent.data.db.entities.MessageAttachment
import com.example.appfunctions.agent.data.db.entities.MessageEntity
import com.example.appfunctions.agent.data.db.entities.MessageProcessingStatus
import com.example.appfunctions.agent.data.db.entities.MessageRole
@@ -35,6 +39,9 @@ import com.example.appfunctions.agent.domain.chat.UpdateMessageUseCase
import com.example.appfunctions.agent.domain.chat.UpdateThreadParams
import com.example.appfunctions.agent.domain.chat.UpdateThreadUseCase
import com.example.appfunctions.agent.domain.pendingintent.SavePendingIntentUseCase
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,353 +52,392 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
-import javax.inject.Inject
-import javax.inject.Singleton
+import org.json.JSONObject
/** Orchestrates the interaction between the LLM, AppFunctions, and the chat repository. */
@Singleton
class AgentOrchestrator
- @Inject
- constructor(
- private val manageThreadsUseCase: ManageThreadsUseCase,
- private val observePendingMessagesUseCase: ObservePendingMessagesUseCase,
- private val sendMessageUseCase: SendMessageUseCase,
- private val updateMessageUseCase: UpdateMessageUseCase,
- private val updateThreadUseCase: UpdateThreadUseCase,
- private val llmProviderFactory: LlmProviderFactory,
- private val settingsRepository: SettingsRepository,
- private val getAppFunctionsUseCase: GetAppFunctionsUseCase,
- private val convertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase,
- private val executeAppFunctionUseCase: ExecuteAppFunctionUseCase,
- private val savePendingIntentUseCase: SavePendingIntentUseCase,
- ) {
- private val _status = MutableStateFlow(AgentStatus.Idle)
-
- /** The current status of the agent. */
- val status: StateFlow = _status.asStateFlow()
-
- /**
- * Observes messages for a specific thread and processes any pending agent responses. This
- * method suspends and collects messages until cancelled.
- */
- suspend fun observeAndProcessMessages(threadId: String) =
- coroutineScope {
- val threadStateFlow =
- manageThreadsUseCase.getThread(threadId).stateIn(this, SharingStarted.Eagerly, null)
-
- observePendingMessagesUseCase(threadId).collect { message ->
- if (message != null) {
- val thread = threadStateFlow.filterNotNull().first()
- processMessage(message, thread)
- }
- }
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val manageThreadsUseCase: ManageThreadsUseCase,
+ private val observePendingMessagesUseCase: ObservePendingMessagesUseCase,
+ private val sendMessageUseCase: SendMessageUseCase,
+ private val updateMessageUseCase: UpdateMessageUseCase,
+ private val updateThreadUseCase: UpdateThreadUseCase,
+ private val llmProviderFactory: LlmProviderFactory,
+ private val settingsRepository: SettingsRepository,
+ private val getAppFunctionsUseCase: GetAppFunctionsUseCase,
+ private val convertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase,
+ private val executeAppFunctionUseCase: ExecuteAppFunctionUseCase,
+ private val savePendingIntentUseCase: SavePendingIntentUseCase
+) {
+ private val _status = MutableStateFlow(AgentStatus.Idle)
+
+ /** The current status of the agent. */
+ val status: StateFlow = _status.asStateFlow()
+
+ /**
+ * Observes messages for a specific thread and processes any pending agent responses. This
+ * method suspends and collects messages until cancelled.
+ */
+ suspend fun observeAndProcessMessages(threadId: String) = coroutineScope {
+ val threadStateFlow =
+ manageThreadsUseCase.getThread(
+ threadId
+ ).stateIn(this, SharingStarted.Eagerly, null)
+
+ observePendingMessagesUseCase(threadId).collect { message ->
+ if (message != null) {
+ val thread = threadStateFlow.filterNotNull().first()
+ processMessage(message, thread)
}
+ }
+ }
- private suspend fun processMessage(
- message: MessageEntity,
- thread: ThreadEntity,
- ) {
- _status.value = AgentStatus.Thinking
-
- try {
- val provider = thread.llmModel.providerName
- val apiKey = getApiKey(provider)
- if (apiKey == null) {
- completeMessageWithError(
- message.messageId,
- message.threadId,
- "API key is missing for ${provider.name}",
- )
- _status.value = AgentStatus.Idle
- return
- }
-
- val disconnectedApps = settingsRepository.disconnectedApps.first()
- val allTools = getAppFunctionsUseCase().first().values.flatten()
-
- val targetPackageName = message.targetPackageName
- val queryText = message.textContent
-
- val tools = filterTools(allTools, disconnectedApps, targetPackageName)
-
- runInteractionLoop(
- message = message,
- thread = thread,
- apiKey = apiKey,
- tools = tools,
- initialInput = queryText,
- )
+ private suspend fun processMessage(message: MessageEntity, thread: ThreadEntity) {
+ _status.value = AgentStatus.Thinking
- _status.value = AgentStatus.Idle
- } catch (e: Exception) {
- Log.e("AgentOrchestrator", "Error processing message", e)
+ try {
+ val provider = thread.llmModel.providerName
+ val apiKey = getApiKey(provider)
+ if (apiKey == null) {
completeMessageWithError(
message.messageId,
message.threadId,
- e.message ?: "Unknown error occurred",
+ "API key is missing for ${provider.name}"
)
_status.value = AgentStatus.Idle
+ return
}
+
+ val disconnectedApps = settingsRepository.disconnectedApps.first()
+ val allTools = getAppFunctionsUseCase().first().values.flatten()
+
+ val targetPackageName = message.targetPackageName
+ val queryText = message.textContent
+
+ val tools = filterTools(allTools, disconnectedApps, targetPackageName)
+
+ runInteractionLoop(
+ message = message,
+ thread = thread,
+ apiKey = apiKey,
+ tools = tools,
+ initialInput = queryText
+ )
+
+ _status.value = AgentStatus.Idle
+ } catch (e: Exception) {
+ Log.e("AgentOrchestrator", "Error processing message", e)
+ completeMessageWithError(
+ message.messageId,
+ message.threadId,
+ e.message ?: "Unknown error occurred"
+ )
+ _status.value = AgentStatus.Idle
}
+ }
- private fun filterTools(
- allTools: List,
- disconnectedApps: Set,
- targetPackageName: String?,
- ): List {
- return allTools.filter { metadata ->
- metadata.isEnabled &&
- metadata.packageName !in disconnectedApps &&
- (targetPackageName == null || metadata.packageName == targetPackageName)
- }
+ private fun filterTools(
+ allTools: List,
+ disconnectedApps: Set,
+ targetPackageName: String?
+ ): List {
+ return allTools.filter { metadata ->
+ metadata.isEnabled &&
+ metadata.packageName !in disconnectedApps &&
+ (targetPackageName == null || metadata.packageName == targetPackageName)
}
+ }
- private suspend fun runInteractionLoop(
- message: MessageEntity,
- thread: ThreadEntity,
- apiKey: String,
- tools: List,
- initialInput: String,
- ) {
- val provider = thread.llmModel.providerName
- val modelName = thread.llmModel.modelName
- val llmProvider = llmProviderFactory.getProvider(provider)
-
- var previousInteractionId = thread.latestInteractionId
- var currentToolOutputs = emptyList()
- var continueLoop = true
- var currentInput = initialInput
-
- while (continueLoop) {
- val llmInput = prepareLlmInput(currentToolOutputs, currentInput)
-
- currentToolOutputs = emptyList()
- val response =
- llmProvider.generateResponse(
- previousInteractionId = previousInteractionId,
- input = llmInput,
- tools = tools,
- apiKey = apiKey,
- modelName = modelName,
- )
+ private suspend fun runInteractionLoop(
+ message: MessageEntity,
+ thread: ThreadEntity,
+ apiKey: String,
+ tools: List,
+ initialInput: String
+ ) {
+ val provider = thread.llmModel.providerName
+ val modelName = thread.llmModel.modelName
+ val llmProvider = llmProviderFactory.getProvider(provider)
+
+ var previousInteractionId = thread.latestInteractionId
+ var currentToolOutputs = emptyList()
+ var continueLoop = true
+ var currentInput = initialInput
+ val capturedAttachments = mutableListOf()
+
+ while (continueLoop) {
+ val llmInput = prepareLlmInput(currentToolOutputs, currentInput)
+
+ currentToolOutputs = emptyList()
+ val response =
+ llmProvider.generateResponse(
+ previousInteractionId = previousInteractionId,
+ input = llmInput,
+ tools = tools,
+ apiKey = apiKey,
+ modelName = modelName
+ )
- when (val handleResult = handleLlmResponse(response, message, tools)) {
- is HandleResult.Continue -> {
- currentToolOutputs = handleResult.toolOutputs
- previousInteractionId = handleResult.interactionId
- }
+ when (
+ val handleResult =
+ handleLlmResponse(response, message, tools, capturedAttachments)
+ ) {
+ is HandleResult.Continue -> {
+ currentToolOutputs = handleResult.toolOutputs
+ previousInteractionId = handleResult.interactionId
+ }
- is HandleResult.Stop -> {
- continueLoop = false
- }
+ is HandleResult.Stop -> {
+ continueLoop = false
}
}
}
+ }
- private suspend fun getApiKey(provider: LlmProviderName): String? {
- return when (provider) {
- LlmProviderName.GEMINI -> settingsRepository.geminiApiKey.first()
- }
+ private suspend fun getApiKey(provider: LlmProviderName): String? {
+ return when (provider) {
+ LlmProviderName.GEMINI -> settingsRepository.geminiApiKey.first()
}
+ }
- private fun prepareLlmInput(
- currentToolOutputs: List,
- currentInput: String,
- ): LlmInput {
- return if (currentToolOutputs.isNotEmpty()) {
- LlmInput.ToolResponse(currentToolOutputs)
- } else {
- LlmInput.UserMessage(currentInput)
- }
+ private fun prepareLlmInput(
+ currentToolOutputs: List,
+ currentInput: String
+ ): LlmInput {
+ return if (currentToolOutputs.isNotEmpty()) {
+ LlmInput.ToolResponse(currentToolOutputs)
+ } else {
+ LlmInput.UserMessage(currentInput)
}
+ }
- private sealed class HandleResult {
- data class Continue(val toolOutputs: List, val interactionId: String) :
- HandleResult()
+ private sealed class HandleResult {
+ data class Continue(val toolOutputs: List, val interactionId: String) :
+ HandleResult()
- object Stop : HandleResult()
- }
+ object Stop : HandleResult()
+ }
- private sealed class ExecuteToolCallsResult {
- data class Success(val toolOutputs: List) : ExecuteToolCallsResult()
+ private sealed class ExecuteToolCallsResult {
+ data class Success(val toolOutputs: List) : ExecuteToolCallsResult()
- data class PendingIntentAction(
- val pendingIntentId: String,
- val pendingIntent: android.app.PendingIntent,
- ) : ExecuteToolCallsResult()
+ data class PendingIntentAction(
+ val pendingIntentId: String,
+ val pendingIntent: android.app.PendingIntent
+ ) : ExecuteToolCallsResult()
- object Error : ExecuteToolCallsResult()
- }
+ object Error : ExecuteToolCallsResult()
+ }
- private suspend fun handleLlmResponse(
- response: LlmResponse,
- message: MessageEntity,
- tools: List,
- ): HandleResult {
- return when (response) {
- is LlmResponse.Success -> {
- updateThreadUseCase(
- message.threadId,
- UpdateThreadParams(interactionId = response.interactionId),
- )
- updateMessageUseCase(
- message.messageId,
- UpdateMessageParams(status = MessageProcessingStatus.PROCESSED),
- )
+ private suspend fun handleLlmResponse(
+ response: LlmResponse,
+ message: MessageEntity,
+ tools: List,
+ capturedAttachments: MutableList
+ ): HandleResult {
+ return when (response) {
+ is LlmResponse.Success -> {
+ updateThreadUseCase(
+ message.threadId,
+ UpdateThreadParams(interactionId = response.interactionId)
+ )
+ updateMessageUseCase(
+ message.messageId,
+ UpdateMessageParams(status = MessageProcessingStatus.PROCESSED)
+ )
- val toolCalls = response.parts.filterIsInstance()
- val textParts = response.parts.filterIsInstance()
-
- val textContent = textParts.joinToString("\n") { it.text }
-
- if (toolCalls.isNotEmpty()) {
- when (val toolResult = executeToolCalls(toolCalls, tools, message)) {
- is ExecuteToolCallsResult.Success -> {
- if (textContent.isNotEmpty()) {
- sendMessageUseCase(
- threadId = message.threadId,
- role = MessageRole.ASSISTANT,
- textContent = textContent,
- processingStatus = MessageProcessingStatus.PROCESSED,
- )
+ val toolCalls = response.parts.filterIsInstance()
+ val textParts = response.parts.filterIsInstance()
+
+ val textContent = textParts.joinToString("\n") { it.text }
+
+ if (toolCalls.isNotEmpty()) {
+ when (val toolResult = executeToolCalls(toolCalls, tools, message)) {
+ is ExecuteToolCallsResult.Success -> {
+ for (output in toolResult.toolOutputs) {
+ runCatching {
+ val json = JSONObject(output.result)
+ val uri = json.optString("imageUri")
+ if (uri.isNotBlank()) {
+ val mimeType =
+ json.optString("mimeType")
+ .takeIf { it.isNotBlank() }
+ ?: "image/png"
+ capturedAttachments.add(
+ MessageAttachment(uri = uri, mimeType = mimeType)
+ )
+ }
}
- HandleResult.Continue(toolResult.toolOutputs, response.interactionId)
}
-
- is ExecuteToolCallsResult.PendingIntentAction -> {
- savePendingIntentUseCase(
- toolResult.pendingIntentId,
- toolResult.pendingIntent,
- )
+ if (textContent.isNotEmpty()) {
sendMessageUseCase(
threadId = message.threadId,
role = MessageRole.ASSISTANT,
textContent = textContent,
- processingStatus = MessageProcessingStatus.PROCESSED,
- pendingIntentId = toolResult.pendingIntentId,
+ processingStatus = MessageProcessingStatus.PROCESSED
)
- HandleResult.Stop
- }
-
- is ExecuteToolCallsResult.Error -> {
- HandleResult.Stop
}
+ HandleResult.Continue(
+ toolResult.toolOutputs,
+ response.interactionId
+ )
}
- } else {
- if (textContent.isNotEmpty()) {
+
+ is ExecuteToolCallsResult.PendingIntentAction -> {
+ savePendingIntentUseCase(
+ toolResult.pendingIntentId,
+ toolResult.pendingIntent
+ )
sendMessageUseCase(
threadId = message.threadId,
role = MessageRole.ASSISTANT,
textContent = textContent,
processingStatus = MessageProcessingStatus.PROCESSED,
+ pendingIntentId = toolResult.pendingIntentId
)
+ HandleResult.Stop
}
- HandleResult.Stop
- }
- }
- is LlmResponse.Error -> {
- Log.e("AgentOrchestrator", "LLM Error: ${response.errorMessage}")
- completeMessageWithError(message.messageId, message.threadId, response.errorMessage)
- _status.value = AgentStatus.Idle
+ is ExecuteToolCallsResult.Error -> {
+ HandleResult.Stop
+ }
+ }
+ } else {
+ if (textContent.isNotEmpty() || capturedAttachments.isNotEmpty()) {
+ sendMessageUseCase(
+ threadId = message.threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = textContent,
+ processingStatus = MessageProcessingStatus.PROCESSED,
+ attachments = capturedAttachments
+ )
+ }
HandleResult.Stop
}
}
- }
- private suspend fun executeToolCalls(
- toolCalls: List,
- tools: List,
- message: MessageEntity,
- ): ExecuteToolCallsResult {
- val results = mutableListOf()
- for (toolCall in toolCalls) {
- _status.value = AgentStatus.InvokingTool(toolCall.functionId, toolCall.packageName)
-
- val matchingTool =
- tools.find {
- it.packageName == toolCall.packageName && it.id == toolCall.functionId
- }
+ is LlmResponse.Error -> {
+ Log.e("AgentOrchestrator", "LLM Error: ${response.errorMessage}")
+ completeMessageWithError(
+ message.messageId,
+ message.threadId,
+ response.errorMessage
+ )
+ _status.value = AgentStatus.Idle
+ HandleResult.Stop
+ }
+ }
+ }
- if (matchingTool == null) {
- completeMessageWithError(
- message.messageId,
- message.threadId,
- "Tool not found: ${toolCall.functionId}",
- )
- return ExecuteToolCallsResult.Error
+ private suspend fun executeToolCalls(
+ toolCalls: List,
+ tools: List,
+ message: MessageEntity
+ ): ExecuteToolCallsResult {
+ val results = mutableListOf()
+ for (toolCall in toolCalls) {
+ _status.value = AgentStatus.InvokingTool(toolCall.functionId, toolCall.packageName)
+
+ val matchingTool =
+ tools.find {
+ it.packageName == toolCall.packageName && it.id == toolCall.functionId
}
- val convertedInputs = toolCall.arguments.filterValues { it != null } as Map
+ if (matchingTool == null) {
+ completeMessageWithError(
+ message.messageId,
+ message.threadId,
+ "Tool not found: ${toolCall.functionId}"
+ )
+ return ExecuteToolCallsResult.Error
+ }
- val appFunctionDataResult =
- withContext(Dispatchers.Default) {
- convertInputToAppFunctionDataUseCase(
- parameters = matchingTool.parameters,
- components = matchingTool.components,
- inputs = convertedInputs,
+ val convertedInputs = toolCall.arguments.filterValues { it != null } as Map
+
+ for (value in convertedInputs.values) {
+ if (value is String && value.startsWith("content://")) {
+ runCatching {
+ val uri = Uri.parse(value)
+ context.grantUriPermission(
+ toolCall.packageName,
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
+ }
+ }
- if (appFunctionDataResult.isFailure) {
- completeMessageWithError(
- message.messageId,
- message.threadId,
- "Failed to convert arguments for ${toolCall.functionId}\n ${appFunctionDataResult.exceptionOrNull()}",
+ val appFunctionDataResult =
+ withContext(Dispatchers.Default) {
+ convertInputToAppFunctionDataUseCase(
+ parameters = matchingTool.parameters,
+ components = matchingTool.components,
+ inputs = convertedInputs
)
- return ExecuteToolCallsResult.Error
}
- val executionResult =
- executeAppFunctionUseCase(
- function = matchingTool,
- parameters = appFunctionDataResult.getOrThrow(),
- threadId = message.threadId,
- )
+ if (appFunctionDataResult.isFailure) {
+ completeMessageWithError(
+ message.messageId,
+ message.threadId,
+ "Failed to convert arguments for ${toolCall.functionId}\n ${appFunctionDataResult.exceptionOrNull()}"
+ )
+ return ExecuteToolCallsResult.Error
+ }
- when (executionResult) {
- is ExecuteAppFunctionResult.Data -> {
- results.add(
- ToolOutput(
- functionId = toolCall.functionId,
- callId = toolCall.callId,
- result = executionResult.formattedJson,
- ),
- )
- }
+ val executionResult =
+ executeAppFunctionUseCase(
+ function = matchingTool,
+ parameters = appFunctionDataResult.getOrThrow(),
+ threadId = message.threadId
+ )
- is ExecuteAppFunctionResult.PendingIntentAction -> {
- val pendingIntentId = java.util.UUID.randomUUID().toString()
- return ExecuteToolCallsResult.PendingIntentAction(
- pendingIntentId,
- executionResult.pendingIntent,
+ when (executionResult) {
+ is ExecuteAppFunctionResult.Data -> {
+ results.add(
+ ToolOutput(
+ functionId = toolCall.functionId,
+ callId = toolCall.callId,
+ result = executionResult.formattedJson
)
- }
+ )
+ }
- is ExecuteAppFunctionResult.Error ->
- throw IllegalStateException(
- "Tool execution failed for ${toolCall.functionId}: ${executionResult.exception.message}",
- executionResult.exception,
- )
+ is ExecuteAppFunctionResult.PendingIntentAction -> {
+ val pendingIntentId = java.util.UUID.randomUUID().toString()
+ return ExecuteToolCallsResult.PendingIntentAction(
+ pendingIntentId,
+ executionResult.pendingIntent
+ )
}
+
+ is ExecuteAppFunctionResult.Error ->
+ throw IllegalStateException(
+ "Tool execution failed for ${toolCall.functionId}: ${executionResult.exception.message}",
+ executionResult.exception
+ )
}
- return ExecuteToolCallsResult.Success(results)
}
+ return ExecuteToolCallsResult.Success(results)
+ }
- private suspend fun completeMessageWithError(
- messageId: String,
- threadId: String,
- reason: String,
- ) {
- updateMessageUseCase(
- messageId,
- UpdateMessageParams(status = MessageProcessingStatus.PROCESSED),
- )
- sendMessageUseCase(
- threadId = threadId,
- role = MessageRole.ASSISTANT,
- textContent = reason,
- processingStatus = MessageProcessingStatus.FAILED,
- )
- }
+ private suspend fun completeMessageWithError(
+ messageId: String,
+ threadId: String,
+ reason: String
+ ) {
+ updateMessageUseCase(
+ messageId,
+ UpdateMessageParams(status = MessageProcessingStatus.PROCESSED)
+ )
+ sendMessageUseCase(
+ threadId = threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = reason,
+ processingStatus = MessageProcessingStatus.FAILED
+ )
}
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/chat/SendMessageUseCase.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/chat/SendMessageUseCase.kt
index 91c3518..ef11560 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/domain/chat/SendMessageUseCase.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/chat/SendMessageUseCase.kt
@@ -16,6 +16,7 @@
package com.example.appfunctions.agent.domain.chat
import com.example.appfunctions.agent.data.ChatRepository
+import com.example.appfunctions.agent.data.db.entities.MessageAttachment
import com.example.appfunctions.agent.data.db.entities.MessageEntity
import com.example.appfunctions.agent.data.db.entities.MessageProcessingStatus
import com.example.appfunctions.agent.data.db.entities.MessageRole
@@ -24,37 +25,39 @@ import javax.inject.Inject
/** Use case to send a message in a chat thread. */
class SendMessageUseCase
- @Inject
- constructor(
- private val chatRepository: ChatRepository,
+@Inject
+constructor(
+ private val chatRepository: ChatRepository
+) {
+ /**
+ * Executes the use case.
+ *
+ * @param threadId The ID of the thread to send the message to.
+ * @param role The role of the sender (USER or ASSISTANT).
+ * @param textContent The content of the message.
+ * @param processingStatus The processing status of the message.
+ */
+ suspend operator fun invoke(
+ threadId: String,
+ role: MessageRole,
+ textContent: String,
+ processingStatus: MessageProcessingStatus,
+ pendingIntentId: String? = null,
+ targetPackageName: String? = null,
+ attachments: List = emptyList()
) {
- /**
- * Executes the use case.
- *
- * @param threadId The ID of the thread to send the message to.
- * @param role The role of the sender (USER or ASSISTANT).
- * @param textContent The content of the message.
- * @param processingStatus The processing status of the message.
- */
- suspend operator fun invoke(
- threadId: String,
- role: MessageRole,
- textContent: String,
- processingStatus: MessageProcessingStatus,
- pendingIntentId: String? = null,
- targetPackageName: String? = null,
- ) {
- val message =
- MessageEntity(
- messageId = UUID.randomUUID().toString(),
- threadId = threadId,
- role = role,
- textContent = textContent,
- timestamp = System.currentTimeMillis(),
- processingStatus = processingStatus,
- pendingIntentId = pendingIntentId,
- targetPackageName = targetPackageName,
- )
- chatRepository.sendMessage(message)
- }
+ val message =
+ MessageEntity(
+ messageId = UUID.randomUUID().toString(),
+ threadId = threadId,
+ role = role,
+ textContent = textContent,
+ timestamp = System.currentTimeMillis(),
+ processingStatus = processingStatus,
+ pendingIntentId = pendingIntentId,
+ targetPackageName = targetPackageName,
+ attachments = attachments
+ )
+ chatRepository.sendMessage(message)
}
+}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt
index 327c8cf..6c9758a 100644
--- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/agentdemo/AgentDemoScreen.kt
@@ -88,6 +88,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -118,6 +119,7 @@ import androidx.compose.ui.window.PopupProperties
import androidx.core.graphics.drawable.toBitmap
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.compose.AsyncImage
import com.example.appfunctions.agent.R
import com.example.appfunctions.agent.data.LlmModel
import com.example.appfunctions.agent.data.db.entities.MessageEntity
@@ -143,7 +145,7 @@ fun AgentDemoScreen(viewModel: AgentDemoViewModel = hiltViewModel()) {
fun AgentDemoContent(
uiState: AgentUiState,
onEvent: (AgentUiEvent) -> Unit,
- initialSidePanelVisible: Boolean = false,
+ initialSidePanelVisible: Boolean = false
) {
val context = LocalContext.current
val packageManager = context.packageManager
@@ -172,7 +174,7 @@ fun AgentDemoContent(
drawerState = drawerState,
scope = scope,
packageManager = packageManager,
- initialSidePanelVisible = initialSidePanelVisible,
+ initialSidePanelVisible = initialSidePanelVisible
)
}
}
@@ -188,7 +190,7 @@ fun AgentDemoContent(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
- drawerContainerColor = MaterialTheme.colorScheme.surface,
+ drawerContainerColor = MaterialTheme.colorScheme.surface
) {
ChatHistorySidePanel(
threads = threads,
@@ -196,10 +198,10 @@ fun AgentDemoContent(
onEvent = { event ->
onEvent(event)
scope.launch { drawerState.close() }
- },
+ }
)
}
- },
+ }
) {
content()
}
@@ -222,7 +224,7 @@ fun AgentDemoLoadedScreen(
drawerState: DrawerState,
scope: CoroutineScope,
packageManager: PackageManager,
- initialSidePanelVisible: Boolean = false,
+ initialSidePanelVisible: Boolean = false
) {
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var isSidePanelVisible by remember { mutableStateOf(initialSidePanelVisible) }
@@ -241,13 +243,13 @@ fun AgentDemoLoadedScreen(
topBar = {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
+ verticalAlignment = Alignment.CenterVertically
) {
ModelDropdown(
modifier =
- Modifier
- .weight(1f)
- .padding(horizontal = 8.dp),
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp),
currentThread = uiState.currentThread,
onModelSelected = { onEvent(AgentUiEvent.OnModelSelected(it)) },
onMenuClick = {
@@ -256,39 +258,39 @@ fun AgentDemoLoadedScreen(
} else {
scope.launch { drawerState.open() }
}
- },
+ }
)
IconButton(
onClick = {
onEvent(AgentUiEvent.OnCreateThread(uiState.currentThread.llmModel))
},
- modifier = Modifier.padding(horizontal = 8.dp),
+ modifier = Modifier.padding(horizontal = 8.dp)
) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Create Thread")
}
}
- },
+ }
) { paddingValues ->
Row(
modifier =
- Modifier
- .fillMaxSize()
- .imePadding()
- .padding(
- top = paddingValues.calculateTopPadding(),
- ),
+ Modifier
+ .fillMaxSize()
+ .imePadding()
+ .padding(
+ top = paddingValues.calculateTopPadding()
+ )
) {
// Side Panel (only for wide screens)
if (isWideScreen) {
AnimatedVisibility(
visible = isSidePanelVisible,
enter = slideInHorizontally() + expandHorizontally(),
- exit = slideOutHorizontally() + shrinkHorizontally(),
+ exit = slideOutHorizontally() + shrinkHorizontally()
) {
ChatHistorySidePanel(
threads = uiState.threads,
currentThread = uiState.currentThread,
- onEvent = onEvent,
+ onEvent = onEvent
)
}
}
@@ -296,20 +298,20 @@ fun AgentDemoLoadedScreen(
// Main Chat Area
Column(
modifier =
- Modifier
- .weight(1f)
- .fillMaxHeight()
- .padding(start = 16.dp, end = 16.dp),
- verticalArrangement = Arrangement.SpaceBetween,
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(start = 16.dp, end = 16.dp),
+ verticalArrangement = Arrangement.SpaceBetween
) {
// Messages List
LazyColumn(
modifier =
- Modifier
- .weight(1f)
- .fillMaxWidth()
- .clip(RoundedCornerShape(16.dp)),
- reverseLayout = true,
+ Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp)),
+ reverseLayout = true
) {
// Status item at the bottom (above input) if not
// idle
@@ -317,21 +319,21 @@ fun AgentDemoLoadedScreen(
item {
StatusIndicator(
status = uiState.status,
- packageManager = packageManager,
+ packageManager = packageManager
)
}
}
items(
items = uiState.messages.reversed(),
- key = { message -> message.messageId },
+ key = { message -> message.messageId }
) { message ->
MessageBubble(
message = message,
isValidAction =
- message.pendingIntentId in uiState.activePendingActionIds,
+ message.pendingIntentId in uiState.activePendingActionIds,
installedApps = uiState.installedApps,
- onConfirmAction = { onEvent(AgentUiEvent.OnConfirmAction(it)) },
+ onConfirmAction = { onEvent(AgentUiEvent.OnConfirmAction(it)) }
)
}
}
@@ -376,12 +378,12 @@ fun AgentDemoLoadedScreen(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
- popupContentSize: IntSize,
+ popupContentSize: IntSize
): IntOffset {
val gap = with(density) { 2.dp.roundToPx() }
return IntOffset(
x = anchorBounds.left,
- y = anchorBounds.top - popupContentSize.height - gap,
+ y = anchorBounds.top - popupContentSize.height - gap
)
}
}
@@ -412,61 +414,61 @@ fun AgentDemoLoadedScreen(
}
},
modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 16.dp)
- .onPreviewKeyEvent { keyEvent ->
- if (
- (keyEvent.key == Key.Enter || keyEvent.key == Key.NumPadEnter) &&
- keyEvent.type == KeyEventType.KeyDown
- ) {
- sendMessage()
- true
- } else {
- false
- }
- },
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ .onPreviewKeyEvent { keyEvent ->
+ if (
+ (keyEvent.key == Key.Enter || keyEvent.key == Key.NumPadEnter) &&
+ keyEvent.type == KeyEventType.KeyDown
+ ) {
+ sendMessage()
+ true
+ } else {
+ false
+ }
+ },
enabled = uiState.status == AgentStatus.Idle,
shape = CircleShape,
placeholder = { Text(stringResource(R.string.agent_demo_ask_agent)) },
visualTransformation = visualTransformation,
colors =
- OutlinedTextFieldDefaults.colors(
- unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
- focusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
- unfocusedBorderColor = Color.Transparent,
- focusedBorderColor = Color.Transparent,
- ),
+ OutlinedTextFieldDefaults.colors(
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ ),
trailingIcon = {
IconButton(
onClick = sendMessage,
enabled =
- messageText.text.isNotBlank() &&
- uiState.status == AgentStatus.Idle,
+ messageText.text.isNotBlank() &&
+ uiState.status == AgentStatus.Idle
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription =
- stringResource(R.string.agent_demo_send),
+ stringResource(R.string.agent_demo_send)
)
}
- },
+ }
)
if (showAutocomplete && filteredApps.isNotEmpty()) {
Popup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = {},
- properties = PopupProperties(focusable = false),
+ properties = PopupProperties(focusable = false)
) {
Card(
modifier = Modifier.fillMaxWidth(0.9f),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors =
- CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceBright,
- ),
- shape = MaterialTheme.shapes.medium,
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright
+ ),
+ shape = MaterialTheme.shapes.medium
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
filteredApps.take(5).forEach { app ->
@@ -477,18 +479,18 @@ fun AgentDemoLoadedScreen(
val selectionStart = messageText.selection.start
val textBeforeCursor =
currentText.take(
- selectionStart,
+ selectionStart
)
val textAfterCursor =
currentText.drop(
- selectionStart,
+ selectionStart
)
val mentionIndex = textBeforeCursor.lastIndexOf('@')
if (mentionIndex >= 0) {
val textBeforeMention =
textBeforeCursor.substring(
0,
- mentionIndex,
+ mentionIndex
)
val newText =
"$textBeforeMention@${app.label} $textAfterCursor"
@@ -498,13 +500,13 @@ fun AgentDemoLoadedScreen(
TextFieldValue(
text = newText,
selection =
- TextRange(
- newCursorPosition,
- ),
+ TextRange(
+ newCursorPosition
+ )
)
selectedAppPackageName = app.packageName
}
- },
+ }
)
}
}
@@ -523,19 +525,19 @@ fun ModelDropdown(
modifier: Modifier = Modifier,
currentThread: ThreadEntity?,
onModelSelected: (LlmModel) -> Unit,
- onMenuClick: () -> Unit,
+ onMenuClick: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = modifier,
expanded = expanded,
- onExpandedChange = { expanded = !expanded },
+ onExpandedChange = { expanded = !expanded }
) {
Surface(
modifier = Modifier.padding(bottom = 8.dp),
shadowElevation = 2.dp,
shape = CircleShape,
- color = MaterialTheme.colorScheme.surfaceBright,
+ color = MaterialTheme.colorScheme.surfaceBright
) {
val text =
currentThread?.llmModel?.modelName
@@ -549,38 +551,38 @@ fun ModelDropdown(
Row(
modifier =
- Modifier
- .fillMaxWidth()
- .height(56.dp)
- .padding(start = 4.dp, end = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(start = 4.dp, end = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onMenuClick) {
Icon(imageVector = Icons.Default.Menu, contentDescription = "Menu")
}
Row(
modifier =
- Modifier
- .weight(1f)
- .fillMaxHeight()
- .menuAnchor(
- ExposedDropdownMenuAnchorType.PrimaryEditable,
- enabled = true,
- ),
- verticalAlignment = Alignment.CenterVertically,
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .menuAnchor(
+ ExposedDropdownMenuAnchorType.PrimaryEditable,
+ enabled = true
+ ),
+ verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.agent_demo_title),
style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
maxLines = 1,
- overflow = TextOverflow.Ellipsis,
+ overflow = TextOverflow.Ellipsis
)
}
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
@@ -593,21 +595,21 @@ fun ModelDropdown(
onDismissRequest = { expanded = false },
modifier = Modifier.exposedDropdownSize(),
containerColor = MaterialTheme.colorScheme.surfaceBright,
- shape = RoundedCornerShape(28.dp),
+ shape = RoundedCornerShape(28.dp)
) {
item {
Text(
"--- Gemini ---",
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
- style = MaterialTheme.typography.labelLarge,
+ style = MaterialTheme.typography.labelLarge
)
}
val models =
listOf(
LlmModel.GEMINI_3_1_PRO_PREVIEW,
LlmModel.GEMINI_3_FLASH_PREVIEW,
- LlmModel.GEMINI_3_1_FLASH_LITE_PREVIEW,
+ LlmModel.GEMINI_3_1_FLASH_LITE_PREVIEW
)
items(models) { model ->
DropdownMenuItem(
@@ -616,7 +618,7 @@ fun ModelDropdown(
onModelSelected(model)
expanded = false
},
- contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp)
)
}
}
@@ -628,7 +630,7 @@ fun MessageBubble(
message: MessageEntity,
isValidAction: Boolean,
installedApps: List,
- onConfirmAction: (String) -> Unit,
+ onConfirmAction: (String) -> Unit
) {
val alignment = if (message.role == MessageRole.USER) Alignment.End else Alignment.Start
val isError = message.processingStatus == MessageProcessingStatus.FAILED
@@ -647,15 +649,15 @@ fun MessageBubble(
Column(
modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp, horizontal = 2.dp),
- horizontalAlignment = alignment,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp, horizontal = 2.dp),
+ horizontalAlignment = alignment
) {
Surface(
shape = MaterialTheme.shapes.large,
color = backgroundColor,
- shadowElevation = if (message.role == MessageRole.ASSISTANT) 1.dp else 0.dp,
+ shadowElevation = if (message.role == MessageRole.ASSISTANT) 1.dp else 0.dp
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
@@ -664,20 +666,24 @@ fun MessageBubble(
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = stringResource(R.string.debugging_error),
- tint = textColor,
+ tint = textColor
)
Spacer(modifier = Modifier.width(8.dp))
}
val contentText =
- if (message.textContent.isEmpty() &&
+ if (
+ message.textContent.isEmpty() &&
message.pendingIntentId != null
) {
stringResource(R.string.agent_demo_action_confirmation_needed)
} else {
message.textContent
}
+
if (message.role != MessageRole.USER) {
- Markdown(content = contentText)
+ if (contentText.isNotEmpty()) {
+ Markdown(content = contentText)
+ }
} else {
val chipBgColor = MaterialTheme.colorScheme.primary
val chipTextColor = MaterialTheme.colorScheme.onPrimary
@@ -695,16 +701,19 @@ fun MessageBubble(
installedApps,
chipBgColor,
chipTextColor,
- density,
+ density
) {
val map = mutableMapOf()
if (installedApps.isNotEmpty() && contentText.contains("@")) {
val appLabelsPattern =
installedApps.joinToString(
- "|",
+ "|"
) { Regex.escape(it.label) }
val regex =
- Regex("@($appLabelsPattern)\\b", RegexOption.IGNORE_CASE)
+ Regex(
+ "@($appLabelsPattern)\\b",
+ RegexOption.IGNORE_CASE
+ )
regex.findAll(contentText).forEachIndexed { index, match ->
val id = "chip_$index"
val appName = match.value
@@ -712,17 +721,17 @@ fun MessageBubble(
textMeasurer.measure(
text = appName,
style =
- typographyStyle.copy(
- fontWeight = FontWeight.Bold,
- ),
+ typographyStyle.copy(
+ fontWeight = FontWeight.Bold
+ )
)
val widthSp =
with(
- density,
+ density
) { (measured.size.width + 8.dp.roundToPx()).toSp() }
val heightSp =
with(
- density,
+ density
) { (measured.size.height + 2.dp.roundToPx()).toSp() }
map[id] =
@@ -730,29 +739,29 @@ fun MessageBubble(
Placeholder(
width = widthSp,
height = heightSp,
- placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
- ),
+ placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter
+ )
) {
Surface(
shape =
- androidx.compose.foundation.shape.RoundedCornerShape(
- 6.dp,
- ),
- color = chipBgColor,
+ androidx.compose.foundation.shape.RoundedCornerShape(
+ 6.dp
+ ),
+ color = chipBgColor
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = appName,
color = chipTextColor,
style =
- typographyStyle.copy(
- fontWeight = FontWeight.Bold,
- ),
+ typographyStyle.copy(
+ fontWeight = FontWeight.Bold
+ ),
modifier =
- Modifier.padding(
- horizontal = 4.dp,
- vertical = 1.dp,
- ),
+ Modifier.padding(
+ horizontal = 4.dp,
+ vertical = 1.dp
+ )
)
}
}
@@ -766,7 +775,7 @@ fun MessageBubble(
text = formattedText,
inlineContent = inlineContentMap,
color = textColor,
- style = typographyStyle,
+ style = typographyStyle
)
}
}
@@ -779,44 +788,59 @@ fun MessageBubble(
enabled = isValidAction,
shape = CircleShape,
colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- ),
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ )
) {
Text(
if (isValidAction) {
stringResource(R.string.agent_demo_confirm_action)
} else {
stringResource(R.string.agent_demo_action_expired)
- },
+ }
)
}
}
}
}
+
+ if (message.role != MessageRole.USER) {
+ message.attachments.forEach { attachment ->
+ if (attachment.mimeType.startsWith("image/", ignoreCase = true)) {
+ Spacer(modifier = Modifier.height(6.dp))
+ AsyncImage(
+ model = attachment.uri,
+ contentDescription = "Generated Image",
+ modifier =
+ Modifier
+ .fillMaxWidth(0.85f)
+ .height(240.dp)
+ .clip(RoundedCornerShape(16.dp)),
+ contentScale = ContentScale.Crop
+ )
+ }
+ }
+ }
}
}
@Composable
-fun StatusIndicator(
- status: AgentStatus,
- packageManager: PackageManager,
-) {
+fun StatusIndicator(status: AgentStatus, packageManager: PackageManager) {
when (status) {
AgentStatus.Thinking -> {
Row(
modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.agent_demo_thinking),
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.bodyMedium
)
}
}
@@ -838,22 +862,22 @@ fun StatusIndicator(
Surface(
modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceBright,
- shadowElevation = 2.dp,
+ shadowElevation = 2.dp
) {
Row(
modifier = Modifier.padding(12.dp),
- verticalAlignment = Alignment.CenterVertically,
+ verticalAlignment = Alignment.CenterVertically
) {
appIcon?.let {
Image(
bitmap = it.toBitmap().asImageBitmap(),
contentDescription = null,
- modifier = Modifier.size(40.dp),
+ modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.width(12.dp))
}
@@ -864,7 +888,7 @@ fun StatusIndicator(
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.agent_demo_connecting),
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.bodyMedium
)
}
}
@@ -883,26 +907,26 @@ fun ChatHistorySidePanel(
threads: List,
currentThread: ThreadEntity?,
onEvent: (AgentUiEvent) -> Unit,
- modifier: Modifier = Modifier,
+ modifier: Modifier = Modifier
) {
Column(
modifier =
- modifier
- .width(280.dp)
- .fillMaxHeight()
- .padding(16.dp),
+ modifier
+ .width(280.dp)
+ .fillMaxHeight()
+ .padding(16.dp)
) {
Text(
text = stringResource(R.string.agent_demo_chat_history),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(bottom = 16.dp),
+ modifier = Modifier.padding(bottom = 16.dp)
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
items = threads,
- key = { thread -> thread.threadId },
+ key = { thread -> thread.threadId }
) { thread ->
val isSelected = thread.threadId == currentThread?.threadId
val backgroundColor =
@@ -920,26 +944,26 @@ fun ChatHistorySidePanel(
Surface(
modifier =
- Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- .clickable {
- onEvent(AgentUiEvent.OnThreadSelected(thread.threadId))
- },
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable {
+ onEvent(AgentUiEvent.OnThreadSelected(thread.threadId))
+ },
shape = MaterialTheme.shapes.medium,
color = backgroundColor,
- contentColor = textColor,
+ contentColor = textColor
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = thread.llmModel.modelName,
style = MaterialTheme.typography.bodyMedium,
- color = textColor,
+ color = textColor
)
Text(
text = "ID: ${thread.threadId.take(8)}",
style = MaterialTheme.typography.bodySmall,
- color = textColor.copy(alpha = 0.7f),
+ color = textColor.copy(alpha = 0.7f)
)
}
}
@@ -950,7 +974,7 @@ fun ChatHistorySidePanel(
class InlineAppScopingVisualTransformation(
private val installedApps: List,
- private val chipTextColor: Color,
+ private val chipTextColor: Color
) : VisualTransformation {
private val regex: Regex? =
if (installedApps.isNotEmpty()) {
@@ -978,8 +1002,8 @@ class InlineAppScopingVisualTransformation(
withStyle(
SpanStyle(
color = chipTextColor,
- fontWeight = FontWeight.Bold,
- ),
+ fontWeight = FontWeight.Bold
+ )
) {
append(match.value)
}
@@ -994,10 +1018,7 @@ class InlineAppScopingVisualTransformation(
}
}
-fun formatMessageText(
- text: String,
- installedApps: List,
-): AnnotatedString {
+fun formatMessageText(text: String, installedApps: List): AnnotatedString {
if (installedApps.isEmpty() || !text.contains("@")) {
return AnnotatedString(text)
}
diff --git a/agent/app/src/main/res/xml/file_paths.xml b/agent/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..5074b23
--- /dev/null
+++ b/agent/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/data/db/entities/MessageAttachmentConverterTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/data/db/entities/MessageAttachmentConverterTest.kt
new file mode 100644
index 0000000..5c665cb
--- /dev/null
+++ b/agent/app/src/test/java/com/example/appfunctions/agent/data/db/entities/MessageAttachmentConverterTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.example.appfunctions.agent.data.db.entities
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MessageAttachmentConverterTest {
+
+ private val converter = MessageAttachmentConverter()
+
+ @Test
+ fun testSerializationAndDeserialization() {
+ val attachments =
+ listOf(
+ MessageAttachment(
+ uri = "content://com.example.appfunctions.agent.fileprovider/cache/test.jpg",
+ mimeType = "image/jpeg"
+ ),
+ MessageAttachment(
+ uri = "content://com.example.appfunctions.agent.fileprovider/cache/test.png",
+ mimeType = "image/png"
+ )
+ )
+
+ val json = converter.fromAttachments(attachments)
+ val decoded = converter.toAttachments(json)
+
+ assertEquals(attachments, decoded)
+ }
+
+ @Test
+ fun testEmptyOrNullStringDeserializesToEmptyList() {
+ assertTrue(converter.toAttachments(null).isEmpty())
+ assertTrue(converter.toAttachments("").isEmpty())
+ }
+
+ @Test
+ fun testMalformedJsonDeserializesToEmptyList() {
+ assertTrue(converter.toAttachments("{invalid_json").isEmpty())
+ }
+}
diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt
index 592d28d..cedc980 100644
--- a/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt
+++ b/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt
@@ -15,6 +15,8 @@
*/
package com.example.appfunctions.agent.domain
+import android.content.Intent
+import androidx.appfunctions.AppFunctionData
import androidx.appfunctions.metadata.AppFunctionMetadata
import androidx.appfunctions.metadata.AppFunctionPackageMetadata
import com.example.appfunctions.agent.data.LlmModel
@@ -25,6 +27,7 @@ import com.example.appfunctions.agent.data.db.entities.MessageProcessingStatus
import com.example.appfunctions.agent.data.db.entities.MessageRole
import com.example.appfunctions.agent.data.db.entities.ThreadEntity
import com.example.appfunctions.agent.domain.appfunction.ConvertInputToAppFunctionDataUseCase
+import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionResult
import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionUseCase
import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase
import com.example.appfunctions.agent.domain.chat.ManageThreadsUseCase
@@ -48,8 +51,10 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
class AgentOrchestratorTest {
private val observePendingMessagesUseCase: ObservePendingMessagesUseCase = mockk()
private val updateMessageUseCase: UpdateMessageUseCase = mockk(relaxed = true)
@@ -61,6 +66,7 @@ class AgentOrchestratorTest {
private val executeAppFunctionUseCase: ExecuteAppFunctionUseCase = mockk()
private val sendMessageUseCase: SendMessageUseCase = mockk(relaxed = true)
private val convertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase = mockk()
+ private val context: android.content.Context = mockk(relaxed = true)
private val savePendingIntentUseCase: SavePendingIntentUseCase = mockk(relaxed = true)
private lateinit var agentOrchestrator: AgentOrchestrator
@@ -69,6 +75,7 @@ class AgentOrchestratorTest {
fun setUp() {
agentOrchestrator =
AgentOrchestrator(
+ context = context,
manageThreadsUseCase = manageThreadsUseCase,
observePendingMessagesUseCase = observePendingMessagesUseCase,
sendMessageUseCase = sendMessageUseCase,
@@ -79,148 +86,145 @@ class AgentOrchestratorTest {
getAppFunctionsUseCase = getAppFunctionsUseCase,
convertInputToAppFunctionDataUseCase = convertInputToAppFunctionDataUseCase,
executeAppFunctionUseCase = executeAppFunctionUseCase,
- savePendingIntentUseCase = savePendingIntentUseCase,
+ savePendingIntentUseCase = savePendingIntentUseCase
)
}
@Test
- fun `observeAndProcessMessages fails when API key is missing`() =
- runTest {
- val threadId = "thread_1"
- val message = createUserMessage(threadId, "Hello", messageId = "msg_1")
- val thread = createThread(threadId)
+ fun `observeAndProcessMessages fails when API key is missing`() = runTest {
+ val threadId = "thread_1"
+ val message = createUserMessage(threadId, "Hello", messageId = "msg_1")
+ val thread = createThread(threadId)
- setupDefaultMocks(threadId, message, thread, apiKey = null)
+ setupDefaultMocks(threadId, message, thread, apiKey = null)
- agentOrchestrator.observeAndProcessMessages(threadId)
+ agentOrchestrator.observeAndProcessMessages(threadId)
- // Verify behavior (state)
- assertEquals(AgentStatus.Idle, agentOrchestrator.status.value)
+ // Verify behavior (state)
+ assertEquals(AgentStatus.Idle, agentOrchestrator.status.value)
- // Verify interactions
- coVerify {
- updateMessageUseCase(
- message.messageId,
- UpdateMessageParams(status = MessageProcessingStatus.PROCESSED),
- )
- sendMessageUseCase(
- threadId = threadId,
- role = MessageRole.ASSISTANT,
- textContent = "API key is missing for GEMINI",
- processingStatus = MessageProcessingStatus.FAILED,
- )
- }
+ // Verify interactions
+ coVerify {
+ updateMessageUseCase(
+ message.messageId,
+ UpdateMessageParams(status = MessageProcessingStatus.PROCESSED)
+ )
+ sendMessageUseCase(
+ threadId = threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = "API key is missing for GEMINI",
+ processingStatus = MessageProcessingStatus.FAILED
+ )
}
+ }
@Test
- fun `observeAndProcessMessages fails when LLM returns error`() =
- runTest {
- val threadId = "thread_1"
- val message = createUserMessage(threadId, "Hello", messageId = "msg_1")
- val thread = createThread(threadId)
- val llmProvider = mockk()
+ fun `observeAndProcessMessages fails when LLM returns error`() = runTest {
+ val threadId = "thread_1"
+ val message = createUserMessage(threadId, "Hello", messageId = "msg_1")
+ val thread = createThread(threadId)
+ val llmProvider = mockk()
- setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
- coEvery { getAppFunctionsUseCase() } returns flowOf(emptyMap())
+ setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
+ coEvery { getAppFunctionsUseCase() } returns flowOf(emptyMap())
- val errorMsg = "LLM failed"
- coEvery { llmProvider.generateResponse(any(), any(), any(), any(), any()) } returns
- LlmResponse.Error(errorMsg)
+ val errorMsg = "LLM failed"
+ coEvery { llmProvider.generateResponse(any(), any(), any(), any(), any()) } returns
+ LlmResponse.Error(errorMsg)
- agentOrchestrator.observeAndProcessMessages(threadId)
+ agentOrchestrator.observeAndProcessMessages(threadId)
- // Verify behavior (state)
- assertEquals(AgentStatus.Idle, agentOrchestrator.status.value)
+ // Verify behavior (state)
+ assertEquals(AgentStatus.Idle, agentOrchestrator.status.value)
- // Verify interactions
- coVerify {
- updateMessageUseCase(
- message.messageId,
- UpdateMessageParams(status = MessageProcessingStatus.PROCESSED),
- )
- sendMessageUseCase(
- threadId = threadId,
- role = MessageRole.ASSISTANT,
- textContent = errorMsg,
- processingStatus = MessageProcessingStatus.FAILED,
- )
- }
+ // Verify interactions
+ coVerify {
+ updateMessageUseCase(
+ message.messageId,
+ UpdateMessageParams(status = MessageProcessingStatus.PROCESSED)
+ )
+ sendMessageUseCase(
+ threadId = threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = errorMsg,
+ processingStatus = MessageProcessingStatus.FAILED
+ )
}
+ }
@Test
- fun `observeAndProcessMessages succeeds when LLM returns text`() =
- runTest {
- val threadId = "thread_1"
- val message = createUserMessage(threadId, "Hello", messageId = "msg_1")
- val thread = createThread(threadId)
- val llmProvider = mockk()
-
- setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
- coEvery { getAppFunctionsUseCase() } returns flowOf(emptyMap())
-
- val responseText = "Hi there"
- coEvery { llmProvider.generateResponse(any(), any(), any(), any(), any()) } returns
- LlmResponse.Success(
- "interaction_123",
- listOf(LlmResponsePart.Text(responseText)),
- )
+ fun `observeAndProcessMessages succeeds when LLM returns text`() = runTest {
+ val threadId = "thread_1"
+ val message = createUserMessage(threadId, "Hello", messageId = "msg_1")
+ val thread = createThread(threadId)
+ val llmProvider = mockk()
+
+ setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
+ coEvery { getAppFunctionsUseCase() } returns flowOf(emptyMap())
+
+ val responseText = "Hi there"
+ coEvery { llmProvider.generateResponse(any(), any(), any(), any(), any()) } returns
+ LlmResponse.Success(
+ "interaction_123",
+ listOf(LlmResponsePart.Text(responseText))
+ )
- agentOrchestrator.observeAndProcessMessages(threadId)
+ agentOrchestrator.observeAndProcessMessages(threadId)
- // Verify behavior (state)
- assertEquals(AgentStatus.Idle, agentOrchestrator.status.value)
+ // Verify behavior (state)
+ assertEquals(AgentStatus.Idle, agentOrchestrator.status.value)
- // Verify interactions
- coVerify {
- updateThreadUseCase(threadId, UpdateThreadParams(interactionId = "interaction_123"))
- sendMessageUseCase(
- threadId = threadId,
- role = MessageRole.ASSISTANT,
- textContent = responseText,
- processingStatus = MessageProcessingStatus.PROCESSED,
- )
- updateMessageUseCase(
- message.messageId,
- UpdateMessageParams(status = MessageProcessingStatus.PROCESSED),
- )
- }
+ // Verify interactions
+ coVerify {
+ updateThreadUseCase(threadId, UpdateThreadParams(interactionId = "interaction_123"))
+ sendMessageUseCase(
+ threadId = threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = responseText,
+ processingStatus = MessageProcessingStatus.PROCESSED
+ )
+ updateMessageUseCase(
+ message.messageId,
+ UpdateMessageParams(status = MessageProcessingStatus.PROCESSED)
+ )
}
+ }
@Test
- fun `observeAndProcessMessages scopes tools when targetPackageName is set`() =
- runTest {
- val threadId = "thread_1"
- val message =
- createUserMessage(
- threadId = threadId,
- textContent = "run geo code address for n1c4ag",
- targetPackageName = "com.google.android.appfunctiontestingagent",
- )
- val thread = createThread(threadId)
- val llmProvider = mockk()
+ fun `observeAndProcessMessages scopes tools when targetPackageName is set`() = runTest {
+ val threadId = "thread_1"
+ val message =
+ createUserMessage(
+ threadId = threadId,
+ textContent = "run geo code address for n1c4ag",
+ targetPackageName = "com.google.android.appfunctiontestingagent"
+ )
+ val thread = createThread(threadId)
+ val llmProvider = mockk()
- val tool1 = createMockTool("com.google.android.appfunctiontestingagent", "run_geo_code")
- val tool2 = createMockTool("com.google.android.digitalwellbeing", "digital_well_being_tool")
- mockAppFunctions(listOf(tool1, tool2))
+ val tool1 = createMockTool("com.google.android.appfunctiontestingagent", "run_geo_code")
+ val tool2 =
+ createMockTool("com.google.android.digitalwellbeing", "digital_well_being_tool")
+ mockAppFunctions(listOf(tool1, tool2))
- setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
+ setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
- coEvery {
- llmProvider.generateResponse(any(), any(), any(), any(), any())
- } returns LlmResponse.Success("interaction_id", listOf(LlmResponsePart.Text("Success")))
+ coEvery {
+ llmProvider.generateResponse(any(), any(), any(), any(), any())
+ } returns LlmResponse.Success("interaction_id", listOf(LlmResponsePart.Text("Success")))
- agentOrchestrator.observeAndProcessMessages(threadId)
+ agentOrchestrator.observeAndProcessMessages(threadId)
- coVerify {
- llmProvider.generateResponse(
- previousInteractionId = null,
- input = eq(LlmInput.UserMessage("run geo code address for n1c4ag")),
- tools = listOf(tool1),
- apiKey = "dummy_key",
- modelName = any(),
- )
- }
+ coVerify {
+ llmProvider.generateResponse(
+ previousInteractionId = null,
+ input = eq(LlmInput.UserMessage("run geo code address for n1c4ag")),
+ tools = listOf(tool1),
+ apiKey = "dummy_key",
+ modelName = any()
+ )
}
+ }
@Test
fun `observeAndProcessMessages does not scope tools when targetPackageName is null`() =
@@ -231,7 +235,8 @@ class AgentOrchestratorTest {
val llmProvider = mockk()
val tool1 = createMockTool("com.google.android.appfunctiontestingagent", "run_geo_code")
- val tool2 = createMockTool("com.google.android.digitalwellbeing", "digital_well_being_tool")
+ val tool2 =
+ createMockTool("com.google.android.digitalwellbeing", "digital_well_being_tool")
mockAppFunctions(listOf(tool1, tool2))
setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
@@ -248,7 +253,7 @@ class AgentOrchestratorTest {
input = eq(LlmInput.UserMessage("run geo code address for n1c4ag")),
tools = listOf(tool1, tool2),
apiKey = "dummy_key",
- modelName = any(),
+ modelName = any()
)
}
}
@@ -257,7 +262,7 @@ class AgentOrchestratorTest {
threadId: String,
textContent: String,
messageId: String = "message_1",
- targetPackageName: String? = null,
+ targetPackageName: String? = null
) = MessageEntity(
messageId = messageId,
threadId = threadId,
@@ -265,26 +270,26 @@ class AgentOrchestratorTest {
textContent = textContent,
timestamp = System.currentTimeMillis(),
processingStatus = MessageProcessingStatus.PENDING_AGENT_RESPONSE,
- targetPackageName = targetPackageName,
+ targetPackageName = targetPackageName
)
private fun createThread(
threadId: String,
llmModel: LlmModel = LlmModel.GEMINI_3_FLASH_PREVIEW,
- latestInteractionId: String? = null,
+ latestInteractionId: String? = null
) = ThreadEntity(
threadId = threadId,
createdAt = System.currentTimeMillis(),
llmModel = llmModel,
- latestInteractionId = latestInteractionId,
+ latestInteractionId = latestInteractionId
)
private fun createMockTool(
packageName: String,
id: String,
- isEnabled: Boolean = true,
+ isEnabled: Boolean = true
): AppFunctionMetadata {
- val tool = mockk()
+ val tool = mockk(relaxed = true)
every { tool.packageName } returns packageName
every { tool.id } returns id
every { tool.isEnabled } returns isEnabled
@@ -302,7 +307,7 @@ class AgentOrchestratorTest {
thread: ThreadEntity,
apiKey: String? = "dummy_key",
disconnectedApps: Set = emptySet(),
- llmProvider: LlmProvider = mockk(),
+ llmProvider: LlmProvider = mockk()
) {
coEvery { observePendingMessagesUseCase(threadId) } returns
flow {
@@ -314,4 +319,160 @@ class AgentOrchestratorTest {
coEvery { settingsRepository.disconnectedApps } returns flowOf(disconnectedApps)
coEvery { llmProviderFactory.getProvider(LlmProviderName.GEMINI) } returns llmProvider
}
+
+ @Test
+ fun `observeAndProcessMessages extracts attachments when tool returns imageUri and mimeType`() =
+ runTest {
+ val threadId = "thread_1"
+ val message = createUserMessage(threadId, "generate image of a dog")
+ val thread = createThread(threadId)
+ val llmProvider = mockk()
+
+ val generateTool = createMockTool("com.example.appfunctions.agent", "generateImage")
+ mockAppFunctions(listOf(generateTool))
+ setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
+
+ val toolCall =
+ LlmResponsePart.ToolCall(
+ packageName = "com.example.appfunctions.agent",
+ functionId = "generateImage",
+ arguments = mapOf("prompt" to "dog"),
+ callId = "call_1"
+ )
+
+ val firstResponse =
+ LlmResponse.Success(
+ interactionId = "interaction_1",
+ parts = listOf(toolCall)
+ )
+ val secondResponse =
+ LlmResponse.Success(
+ interactionId = "interaction_2",
+ parts = listOf(LlmResponsePart.Text("Here is your image!"))
+ )
+
+ coEvery {
+ llmProvider.generateResponse(
+ previousInteractionId = null,
+ input = any(),
+ tools = any(),
+ apiKey = any(),
+ modelName = any()
+ )
+ } returns firstResponse
+
+ coEvery {
+ llmProvider.generateResponse(
+ previousInteractionId = "interaction_1",
+ input = any(),
+ tools = any(),
+ apiKey = any(),
+ modelName = any()
+ )
+ } returns secondResponse
+
+ coEvery {
+ convertInputToAppFunctionDataUseCase(any(), any(), any())
+ } returns Result.success(AppFunctionData.EMPTY)
+
+ coEvery {
+ executeAppFunctionUseCase(any(), any(), any())
+ } returns
+ ExecuteAppFunctionResult.Data(
+ data = AppFunctionData.EMPTY,
+ formattedJson = """{"imageUri":"content://com.example.appfunctions.agent.fileprovider/cache/test.jpg","mimeType":"image/jpeg","prompt":"dog"}"""
+ )
+
+ agentOrchestrator.observeAndProcessMessages(threadId)
+
+ coVerify {
+ sendMessageUseCase(
+ threadId = threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = "Here is your image!",
+ processingStatus = MessageProcessingStatus.PROCESSED,
+ pendingIntentId = null,
+ targetPackageName = null,
+ attachments =
+ listOf(
+ com.example.appfunctions.agent.data.db.entities.MessageAttachment(
+ uri = "content://com.example.appfunctions.agent.fileprovider/cache/test.jpg",
+ mimeType = "image/jpeg"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `observeAndProcessMessages grants URI read permission when tool is called with content URI argument`() =
+ runTest {
+ val threadId = "thread_1"
+ val message = createUserMessage(threadId, "set wallpaper")
+ val thread = createThread(threadId)
+ val llmProvider = mockk()
+
+ val targetTool = createMockTool("com.example.targetapp", "setWallpaper")
+ mockAppFunctions(listOf(targetTool))
+ setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider)
+
+ val contentUri = "content://com.example.appfunctions.agent.fileprovider/cache/img.jpg"
+ val toolCall =
+ LlmResponsePart.ToolCall(
+ packageName = "com.example.targetapp",
+ functionId = "setWallpaper",
+ arguments = mapOf("uri" to contentUri),
+ callId = "call_2"
+ )
+
+ coEvery {
+ llmProvider.generateResponse(
+ previousInteractionId = null,
+ input = any(),
+ tools = any(),
+ apiKey = any(),
+ modelName = any()
+ )
+ } returns
+ LlmResponse.Success(
+ interactionId = "interaction_1",
+ parts = listOf(toolCall)
+ )
+
+ coEvery {
+ llmProvider.generateResponse(
+ previousInteractionId = "interaction_1",
+ input = any(),
+ tools = any(),
+ apiKey = any(),
+ modelName = any()
+ )
+ } returns
+ LlmResponse.Success(
+ interactionId = "interaction_2",
+ parts = listOf(LlmResponsePart.Text("Wallpaper set"))
+ )
+
+ coEvery {
+ convertInputToAppFunctionDataUseCase(any(), any(), any())
+ } returns Result.success(AppFunctionData.EMPTY)
+
+ coEvery {
+ executeAppFunctionUseCase(any(), any(), any())
+ } returns
+ ExecuteAppFunctionResult.Data(
+ data = AppFunctionData.EMPTY,
+ formattedJson = """{"success":true}"""
+ )
+
+ agentOrchestrator.observeAndProcessMessages(threadId)
+
+ coVerify {
+ context.grantUriPermission(
+ eq("com.example.targetapp"),
+ any(),
+ eq(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ )
+ }
+ }
}
diff --git a/agent/gradle/libs.versions.toml b/agent/gradle/libs.versions.toml
index f363f9a..c71351d 100644
--- a/agent/gradle/libs.versions.toml
+++ b/agent/gradle/libs.versions.toml
@@ -92,6 +92,7 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" }