diff --git a/agent/app/build.gradle.kts b/agent/app/build.gradle.kts
index ebf4382..08ef816 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,7 +61,10 @@ 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(
diff --git a/agent/app/src/main/AndroidManifest.xml b/agent/app/src/main/AndroidManifest.xml
index 7fa52ed..699a2e4 100644
--- a/agent/app/src/main/AndroidManifest.xml
+++ b/agent/app/src/main/AndroidManifest.xml
@@ -59,6 +59,16 @@
android:exported="false"
tools:replace="android:label,android:theme" />
+
+
+
+
- geocoder.getFromLocationName(
- address,
- 1,
- object : Geocoder.GeocodeListener {
- override fun onGeocode(addresses: MutableList) {
- val location = addresses.firstOrNull()
- if (location != null) {
- continuation.resume(
- LatLng(location.latitude, location.longitude),
- )
- } else {
- continuation.resume(null)
- }
- }
-
- override fun onError(errorMessage: String?) {
- continuation.resume(null)
- }
- },
- )
- }
- } catch (e: Exception) {
- throw IllegalStateException(e.message, e)
- }
- }
- }
-
- /**
- * Retrieves the current latitude and longitude coordinates of the device.
- *
- * @return The current location coordinates of the device, or null if location is unavailable or
- * permission is denied.
- */
- @SuppressLint("MissingPermission")
- @AppFunction(isDescribedByKDoc = true)
- suspend fun getCurrentLocation(): LatLng? =
- withContext(Dispatchers.Default) {
- val context = this@AbstractBuiltInAppFunctions
-
- // Check permissions
- val hasFineLocation =
- ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
- PackageManager.PERMISSION_GRANTED
-
- val hasCoarseLocation =
- ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.ACCESS_COARSE_LOCATION,
- ) ==
- PackageManager.PERMISSION_GRANTED
-
- if (!hasFineLocation && !hasCoarseLocation) {
- throw IllegalStateException("Location permission is not granted")
- }
-
- val locationManager =
- context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
-
- try {
- // Try GPS Provider first
- var location =
- if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
- locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
- } else {
- null
- }
-
- // Fallback to Network Provider if GPS is not available
- if (location == null &&
- locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
- ) {
- location =
- locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
- }
-
- if (location != null) {
- LatLng(location.latitude, location.longitude)
- } else {
- null
- }
- } catch (e: Exception) {
- throw IllegalStateException(e.message, e)
- }
- }
-
- /** Represents the latitude and longitude coordinates. */
- @AppFunctionSerializable(isDescribedByKDoc = true)
- data class LatLng(
- /** The latitude coordinate. */
- val latitude: Double,
- /** The longitude coordinate. */
- val longitude: Double,
- )
-}
diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/BaseBuiltInAppFunctionService.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/BaseBuiltInAppFunctionService.kt
new file mode 100644
index 0000000..2e1eb54
--- /dev/null
+++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/BaseBuiltInAppFunctionService.kt
@@ -0,0 +1,360 @@
+/*
+ * 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
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Address
+import android.location.Geocoder
+import android.location.LocationManager
+import android.util.Base64
+import androidx.annotation.RequiresApi
+import androidx.appfunctions.AppFunction
+import androidx.appfunctions.AppFunctionSerializable
+import androidx.appfunctions.AppFunctionService
+import androidx.appfunctions.AppFunctionServiceEntryPoint
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.example.appfunctions.agent.BuildConfig
+import com.example.appfunctions.agent.di.settingsDataStore
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.UUID
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+/** Built-in AppFunctions for location and geocoding services. */
+@RequiresApi(36)
+@AndroidEntryPoint
+@AppFunctionServiceEntryPoint(
+ serviceName = "BuiltInAppFunctionService",
+ appFunctionXmlFileName = "builtin_app_function_service",
+)
+abstract class BaseBuiltInAppFunctionService : AppFunctionService() {
+ /**
+ * Geocode a physical address string into its latitude and longitude coordinates.
+ *
+ * @param address The physical address to geocode (e.g., "1600 Amphitheatre Pkwy, Mountain View,
+ * CA").
+ * @return The latitude and longitude coordinates of the address, or null if geocoding fails.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun geocodeAddress(address: String): LatLng? {
+ if (!Geocoder.isPresent()) {
+ return null
+ }
+
+ val geocoder = Geocoder(this)
+
+ return withContext(Dispatchers.IO) {
+ try {
+ suspendCoroutine { continuation ->
+ geocoder.getFromLocationName(
+ address,
+ 1,
+ object : Geocoder.GeocodeListener {
+ override fun onGeocode(addresses: MutableList) {
+ val location = addresses.firstOrNull()
+ if (location != null) {
+ continuation.resume(
+ LatLng(location.latitude, location.longitude),
+ )
+ } else {
+ continuation.resume(null)
+ }
+ }
+
+ override fun onError(errorMessage: String?) {
+ continuation.resume(null)
+ }
+ },
+ )
+ }
+ } catch (e: Exception) {
+ throw IllegalStateException(e.message, e)
+ }
+ }
+ }
+
+ /**
+ * Retrieve the current latitude and longitude coordinates of the device.
+ *
+ * @return The current location coordinates of the device, or null if location is unavailable or
+ * permission is denied.
+ */
+ @SuppressLint("MissingPermission")
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getCurrentLocation(): LatLng? =
+ withContext(Dispatchers.Default) {
+ // Check permissions
+ val hasFineLocation =
+ ContextCompat.checkSelfPermission(
+ this@BaseBuiltInAppFunctionService,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ val hasCoarseLocation =
+ ContextCompat.checkSelfPermission(
+ this@BaseBuiltInAppFunctionService,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!hasFineLocation && !hasCoarseLocation) {
+ throw IllegalStateException("Location permission is not granted")
+ }
+
+ val locationManager =
+ this@BaseBuiltInAppFunctionService.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+
+ try {
+ // Try GPS Provider first
+ var location =
+ if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
+ } else {
+ null
+ }
+
+ // Fallback to Network Provider if GPS is not available
+ if (location == null &&
+ locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
+ ) {
+ location =
+ locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
+ }
+
+ if (location != null) {
+ LatLng(location.latitude, location.longitude)
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ throw IllegalStateException(e.message, e)
+ }
+ }
+
+ /** Represents the latitude and longitude coordinates. */
+ @AppFunctionSerializable(isDescribedByKDoc = true)
+ data class LatLng(
+ /** The latitude coordinate. */
+ val latitude: Double,
+ /** The longitude coordinate. */
+ val longitude: Double,
+ )
+
+ /**
+ * Generates an image from a text prompt and returns the remote image URI.
+ *
+ * @param prompt The text prompt describing the image to generate (e.g., "futuristic cityscape at sunset").
+ * @param aspectRatio Optional aspect ratio for the image (e.g., "16:9", "1:1").
+ * @return A GeneratedImageResult containing the generated remote image URI.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun generateImage(
+ prompt: String,
+ aspectRatio: String? = null,
+ ): GeneratedImageResult =
+ withContext(Dispatchers.IO) {
+ val apiKey = getOrFetchApiKey()
+ val requestPayload = buildImageGenerationPayload(prompt, aspectRatio)
+ val responseText = executeImageRequest(apiKey, requestPayload)
+ saveBase64ImageToCache(responseText, prompt)
+ }
+
+ private suspend fun getOrFetchApiKey(): String {
+ val apiKey =
+ settingsDataStore.data
+ .first()[stringPreferencesKey("gemini_api_key")]
+ ?.takeIf { it.isNotBlank() }
+ ?: BuildConfig.GEMINI_API_KEY.takeIf { it.isNotBlank() }
+ if (apiKey.isNullOrBlank()) {
+ throw IllegalStateException(
+ "Gemini API key is not configured. Please set gemini_api_key in settings.",
+ )
+ }
+ return apiKey
+ }
+
+ private fun buildImageGenerationPayload(
+ prompt: String,
+ aspectRatio: String?,
+ ): JSONObject =
+ JSONObject().apply {
+ put(
+ "contents",
+ JSONArray().apply {
+ put(
+ JSONObject().apply {
+ put(
+ "parts",
+ JSONArray().apply {
+ put(JSONObject().apply { put("text", prompt) })
+ },
+ )
+ },
+ )
+ },
+ )
+ put(
+ "generationConfig",
+ JSONObject().apply {
+ put("responseModalities", JSONArray().apply { put("IMAGE") })
+ if (!aspectRatio.isNullOrBlank()) {
+ put(
+ "imageConfig",
+ JSONObject().apply {
+ put("aspectRatio", aspectRatio)
+ },
+ )
+ }
+ },
+ )
+ }
+
+ private fun executeImageRequest(
+ apiKey: String,
+ requestJson: JSONObject,
+ ): String {
+ val endpointUrl =
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent?key=$apiKey"
+ val url = URL(endpointUrl)
+ val connection =
+ (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ setRequestProperty("Content-Type", "application/json")
+ doOutput = true
+ connectTimeout = 30000
+ readTimeout = 60000
+ }
+
+ try {
+ connection.outputStream.use { os ->
+ 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",
+ )
+ }
+
+ return connection.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ private fun saveBase64ImageToCache(
+ responseText: String,
+ prompt: String,
+ ): GeneratedImageResult {
+ 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",
+ )
+ }
+
+ val candidateData =
+ (0 until parts.length())
+ .asSequence()
+ .mapNotNull { i ->
+ val part = parts.getJSONObject(i)
+ val inlineData =
+ part.optJSONObject("inlineData")
+ ?: part.optJSONObject("inline_data")
+ if (inlineData != null) {
+ val base64 = inlineData.optString("data")
+ val returnedMime =
+ inlineData
+ .optString("mimeType")
+ .takeIf { it.isNotBlank() }
+ ?: inlineData.optString("mime_type")
+ .takeIf { it.isNotBlank() }
+ ?: "image/png"
+ base64 to returnedMime
+ } else {
+ null
+ }
+ }
+ .firstOrNull()
+
+ if (candidateData == null || candidateData.first.isBlank()) {
+ throw IllegalStateException(
+ "No inlineData image found in response parts. Gemini response: $responseText",
+ )
+ }
+
+ val (base64Data, mimeType) = candidateData
+ val imageBytes = Base64.decode(base64Data, Base64.DEFAULT)
+ val extension =
+ when {
+ mimeType.contains("jpeg") || mimeType.contains("jpg") -> "jpg"
+ else -> "png"
+ }
+ val cachedFile =
+ File(
+ cacheDir,
+ "generated_${UUID.randomUUID()}.$extension",
+ )
+ cachedFile.writeBytes(imageBytes)
+
+ val authority = "$packageName.fileprovider"
+ val contentUri = FileProvider.getUriForFile(this, authority, cachedFile)
+ return GeneratedImageResult(
+ imageUri = contentUri.toString(),
+ mimeType = mimeType,
+ prompt = prompt,
+ )
+ }
+
+ /** 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..f6a2310 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
@@ -58,18 +58,20 @@ class GeminiProviderImpl
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) }
+ tools
+ .distinctBy { toolConverter.getToolName(it) }
+ .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
}
- } catch (e: IllegalArgumentException) {
- Log.e(TAG, "Failed to convert tool ${tool.id}: ${e.message}", e)
- null
}
- }
val requestBody =
buildJsonObject {
@@ -187,7 +189,9 @@ class GeminiProviderImpl
}
val callId =
stepObj["id"]?.jsonPrimitive?.content
- ?: return LlmResponse.Error("Function call missing call_id in response")
+ ?: return LlmResponse.Error(
+ "Function call missing call_id in response",
+ )
parts.add(
LlmResponsePart.ToolCall(
packageName = packageName,
@@ -223,7 +227,12 @@ class GeminiProviderImpl
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."
+ 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 {
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..32022ba 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,6 +19,9 @@ 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",
@@ -46,8 +49,35 @@ 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,
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..3757f9d 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
@@ -42,7 +42,7 @@ import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import javax.inject.Singleton
-private val Context.settingsDataStore: DataStore by
+val Context.settingsDataStore: DataStore by
preferencesDataStore(name = "settings")
@Module
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 8ba18b0..b2082a9 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
@@ -16,10 +16,14 @@
package com.example.appfunctions.agent.domain
import android.app.PendingIntent
+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
@@ -37,6 +41,7 @@ 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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@@ -48,6 +53,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
+import org.json.JSONObject
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -57,6 +63,7 @@ import javax.inject.Singleton
class AgentOrchestrator
@Inject
constructor(
+ @ApplicationContext private val context: Context,
private val manageThreadsUseCase: ManageThreadsUseCase,
private val observePendingMessagesUseCase: ObservePendingMessagesUseCase,
private val sendMessageUseCase: SendMessageUseCase,
@@ -81,7 +88,9 @@ class AgentOrchestrator
suspend fun observeAndProcessMessages(threadId: String) =
coroutineScope {
val threadStateFlow =
- manageThreadsUseCase.getThread(threadId).stateIn(this, SharingStarted.Eagerly, null)
+ manageThreadsUseCase.getThread(
+ threadId,
+ ).stateIn(this, SharingStarted.Eagerly, null)
observePendingMessagesUseCase(threadId).collect { message ->
if (message != null) {
@@ -143,11 +152,14 @@ class AgentOrchestrator
disconnectedApps: Set,
targetPackageName: String?,
): List {
- return allTools.filter { metadata ->
- metadata.isEnabled &&
- metadata.packageName !in disconnectedApps &&
- (targetPackageName == null || metadata.packageName == targetPackageName)
- }
+ return allTools
+ .filter { metadata ->
+ metadata.isEnabled &&
+ metadata.packageName !in disconnectedApps &&
+ (targetPackageName == null || metadata.packageName == targetPackageName)
+ }
+ .sortedByDescending { metadata -> metadata.id.startsWith(metadata.packageName) }
+ .distinctBy { metadata -> metadata.id }
}
private suspend fun runInteractionLoop(
@@ -165,6 +177,7 @@ class AgentOrchestrator
var currentToolOutputs = emptyList()
var continueLoop = true
var currentInput = initialInput
+ val capturedAttachments = mutableListOf()
while (continueLoop) {
val llmInput = prepareLlmInput(currentToolOutputs, currentInput)
@@ -179,7 +192,10 @@ class AgentOrchestrator
modelName = modelName,
)
- when (val handleResult = handleLlmResponse(response, message, tools)) {
+ when (
+ val handleResult =
+ handleLlmResponse(response, message, tools, capturedAttachments)
+ ) {
is HandleResult.Continue -> {
currentToolOutputs = handleResult.toolOutputs
previousInteractionId = handleResult.interactionId
@@ -231,6 +247,7 @@ class AgentOrchestrator
response: LlmResponse,
message: MessageEntity,
tools: List,
+ capturedAttachments: MutableList,
): HandleResult {
return when (response) {
is LlmResponse.Success -> {
@@ -251,6 +268,21 @@ class AgentOrchestrator
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),
+ )
+ }
+ }
+ }
if (textContent.isNotEmpty()) {
sendMessageUseCase(
threadId = message.threadId,
@@ -259,7 +291,10 @@ class AgentOrchestrator
processingStatus = MessageProcessingStatus.PROCESSED,
)
}
- HandleResult.Continue(toolResult.toolOutputs, response.interactionId)
+ HandleResult.Continue(
+ toolResult.toolOutputs,
+ response.interactionId,
+ )
}
is ExecuteToolCallsResult.PendingIntentAction -> {
@@ -282,12 +317,13 @@ class AgentOrchestrator
}
}
} else {
- if (textContent.isNotEmpty()) {
+ if (textContent.isNotEmpty() || capturedAttachments.isNotEmpty()) {
sendMessageUseCase(
threadId = message.threadId,
role = MessageRole.ASSISTANT,
textContent = textContent,
processingStatus = MessageProcessingStatus.PROCESSED,
+ attachments = capturedAttachments,
)
}
HandleResult.Stop
@@ -296,7 +332,11 @@ class AgentOrchestrator
is LlmResponse.Error -> {
Log.e("AgentOrchestrator", "LLM Error: ${response.errorMessage}")
- completeMessageWithError(message.messageId, message.threadId, response.errorMessage)
+ completeMessageWithError(
+ message.messageId,
+ message.threadId,
+ response.errorMessage,
+ )
_status.value = AgentStatus.Idle
HandleResult.Stop
}
@@ -328,6 +368,19 @@ class AgentOrchestrator
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,
+ )
+ }
+ }
+ }
+
val appFunctionDataResult =
withContext(Dispatchers.Default) {
convertInputToAppFunctionDataUseCase(
@@ -365,7 +418,7 @@ class AgentOrchestrator
}
is ExecuteAppFunctionResult.PendingIntentAction -> {
- val pendingIntentId = UUID.randomUUID().toString()
+ val pendingIntentId = java.util.UUID.randomUUID().toString()
return ExecuteToolCallsResult.PendingIntentAction(
pendingIntentId,
executionResult.pendingIntent,
@@ -377,7 +430,8 @@ class AgentOrchestrator
if (exception is CancellationException) {
throw exception
}
- val appFunctionException = AppFunctionExceptionFormatter.getAppFunctionException(exception)
+ val appFunctionException =
+ AppFunctionExceptionFormatter.getAppFunctionException(exception)
if (appFunctionException != null) {
results.add(
ToolOutput(
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..4f9ac22 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
@@ -43,6 +44,7 @@ class SendMessageUseCase
processingStatus: MessageProcessingStatus,
pendingIntentId: String? = null,
targetPackageName: String? = null,
+ attachments: List = emptyList(),
) {
val message =
MessageEntity(
@@ -54,6 +56,7 @@ class SendMessageUseCase
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..193e37c 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
@@ -669,15 +671,19 @@ fun MessageBubble(
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
@@ -704,7 +710,10 @@ fun MessageBubble(
"|",
) { 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
@@ -795,6 +804,24 @@ fun MessageBubble(
}
}
}
+
+ 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,
+ )
+ }
+ }
+ }
}
}
diff --git a/agent/app/src/main/res/drawable/ic_launcher_background.xml b/agent/app/src/main/res/drawable/ic_launcher_background.xml
index 7c323a2..41fdccf 100644
--- a/agent/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/agent/app/src/main/res/drawable/ic_launcher_background.xml
@@ -34,7 +34,7 @@
-
+
+
+
+
+
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..7a7994e
--- /dev/null
+++ b/agent/app/src/test/java/com/example/appfunctions/agent/data/db/entities/MessageAttachmentConverterTest.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 d424089..f72670b 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,11 +15,14 @@
*/
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
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
@@ -64,6 +67,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
@@ -72,6 +76,7 @@ class AgentOrchestratorTest {
fun setUp() {
agentOrchestrator =
AgentOrchestrator(
+ context = context,
manageThreadsUseCase = manageThreadsUseCase,
observePendingMessagesUseCase = observePendingMessagesUseCase,
sendMessageUseCase = sendMessageUseCase,
@@ -203,7 +208,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)
@@ -234,7 +240,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)
@@ -356,7 +363,7 @@ class AgentOrchestratorTest {
id: String,
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
@@ -386,4 +393,139 @@ 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",
+ )
+ setupTwoStepToolCallAndExecution(
+ llmProvider = llmProvider,
+ toolCall = toolCall,
+ secondResponseText = "Here is your image!",
+ toolResultJson =
+ """{"imageUri":"content://com.example.appfunctions.agent.fileprovider/cache/test.jpg",""" +
+ """"mimeType":"image/jpeg","prompt":"dog"}""",
+ )
+
+ agentOrchestrator.observeAndProcessMessages(threadId)
+
+ val testUri =
+ "content://com.example.appfunctions.agent.fileprovider/cache/test.jpg"
+ coVerify {
+ sendMessageUseCase(
+ threadId = threadId,
+ role = MessageRole.ASSISTANT,
+ textContent = "Here is your image!",
+ processingStatus = MessageProcessingStatus.PROCESSED,
+ pendingIntentId = null,
+ targetPackageName = null,
+ attachments = listOf(MessageAttachment(uri = testUri, 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",
+ )
+ setupTwoStepToolCallAndExecution(
+ llmProvider = llmProvider,
+ toolCall = toolCall,
+ secondResponseText = "Wallpaper set",
+ toolResultJson = """{"success":true}""",
+ )
+
+ agentOrchestrator.observeAndProcessMessages(threadId)
+
+ coVerify {
+ context.grantUriPermission(
+ eq("com.example.targetapp"),
+ any(),
+ eq(Intent.FLAG_GRANT_READ_URI_PERMISSION),
+ )
+ }
+ }
+ }
+
+ private fun setupTwoStepToolCallAndExecution(
+ llmProvider: LlmProvider,
+ toolCall: LlmResponsePart.ToolCall,
+ secondResponseText: String,
+ toolResultJson: String,
+ ) {
+ val firstResponse =
+ LlmResponse.Success(
+ interactionId = "interaction_1",
+ parts = listOf(toolCall),
+ )
+ val secondResponse =
+ LlmResponse.Success(
+ interactionId = "interaction_2",
+ parts = listOf(LlmResponsePart.Text(secondResponseText)),
+ )
+
+ 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 = toolResultJson,
+ )
+ }
}
diff --git a/agent/gradle/libs.versions.toml b/agent/gradle/libs.versions.toml
index 7e9bef4..466ba94 100644
--- a/agent/gradle/libs.versions.toml
+++ b/agent/gradle/libs.versions.toml
@@ -91,6 +91,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" }