Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import androidx.appfunctions.AppFunctionManager
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.example.appfunctions.agent.data.AgentInternalTools
import com.example.appfunctions.agent.data.DataStoreSettingsRepository
import com.example.appfunctions.agent.di.settingsDataStore
import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionStatesUseCase
import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
Expand All @@ -38,7 +42,14 @@ class GetAppFunctionsUseCaseTest {
fun setup() {
context = ApplicationProvider.getApplicationContext()
appFunctionManager = AppFunctionManager.getInstance(context)!!
useCase = GetAppFunctionsUseCase(appFunctionManager)
val dataStore = context.settingsDataStore
useCase =
GetAppFunctionsUseCase(
appFunctionManager = appFunctionManager,
getAppFunctionStatesUseCase = GetAppFunctionStatesUseCase(appFunctionManager),
settingsRepository = DataStoreSettingsRepository(dataStore),
agentInternalTools = AgentInternalTools(context, dataStore),
)

// Adopt shell permission identity
InstrumentationRegistry.getInstrumentation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import android.location.Geocoder
import android.location.LocationManager
import android.util.Base64
import androidx.annotation.RequiresApi
import androidx.appfunctions.AppFunctionData
import androidx.appfunctions.AppFunctionName
import androidx.appfunctions.AppFunctionState
import androidx.appfunctions.ExecuteAppFunctionResponse.Success.Companion.PROPERTY_RETURN_VALUE
import androidx.appfunctions.metadata.AppFunctionComponentsMetadata
import androidx.appfunctions.metadata.AppFunctionDoubleTypeMetadata
import androidx.appfunctions.metadata.AppFunctionMetadata
Expand All @@ -40,7 +42,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.example.appfunctions.agent.BuildConfig
import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionResult
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -375,6 +379,12 @@ class AgentInternalTools
qualifiedName = "com.example.appfunctions.agent.data.AgentInternalTools.GeneratedImageResult",
)

val internalPackageMetadata =
AppFunctionPackageMetadata(
packageName = INTERNAL_TOOL_PACKAGE,
components = AppFunctionComponentsMetadata(),
)

val getCurrentLocationTool =
AppFunctionMetadata(
name = AppFunctionName(INTERNAL_TOOL_PACKAGE, "getCurrentLocation"),
Expand All @@ -387,11 +397,7 @@ class AgentInternalTools
),
description = "Retrieve the current latitude and longitude coordinates of the device.",
deprecation = null,
packageMetadata =
AppFunctionPackageMetadata(
packageName = INTERNAL_TOOL_PACKAGE,
components = AppFunctionComponentsMetadata(),
),
packageMetadata = internalPackageMetadata,
)

val geocodeAddressTool =
Expand All @@ -414,11 +420,7 @@ class AgentInternalTools
),
description = "Geocode a physical address string into its latitude and longitude coordinates.",
deprecation = null,
packageMetadata =
AppFunctionPackageMetadata(
packageName = INTERNAL_TOOL_PACKAGE,
components = AppFunctionComponentsMetadata(),
),
packageMetadata = internalPackageMetadata,
)

val generateImageTool =
Expand Down Expand Up @@ -447,11 +449,7 @@ class AgentInternalTools
),
description = "Generates an image from a text prompt and returns the remote image URI.",
deprecation = null,
packageMetadata =
AppFunctionPackageMetadata(
packageName = INTERNAL_TOOL_PACKAGE,
components = AppFunctionComponentsMetadata(),
),
packageMetadata = internalPackageMetadata,
)

@SuppressLint("RestrictedApi")
Expand All @@ -462,6 +460,115 @@ class AgentInternalTools
)
}

/**
* Executes an internal agent tool identified by [function] using [parameters] and maps the
* output into an [ExecuteAppFunctionResult].
*/
suspend fun executeInternalTool(
function: AppFunctionMetadata,
parameters: AppFunctionData,
): ExecuteAppFunctionResult {
return try {
when (function.id) {
"getCurrentLocation" -> {
val location = getCurrentLocation()
buildLatLngResult(function, location)
}
"geocodeAddress" -> {
val address =
parameters.getString("address")
?: throw IllegalArgumentException(
"Missing required parameter 'address'",
)
val location = geocodeAddress(address)
buildLatLngResult(function, location)
}
"generateImage" -> {
val prompt =
parameters.getString("prompt")
?: throw IllegalArgumentException(
"Missing required parameter 'prompt'",
)
val aspectRatio = parameters.getString("aspectRatio")
val result = generateImage(prompt, aspectRatio)
buildGeneratedImageResult(function, result)
}
else ->
throw IllegalArgumentException(
"Unknown internal tool: ${function.id}",
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ExecuteAppFunctionResult.Error(e)
}
}

private fun buildLatLngResult(
function: AppFunctionMetadata,
location: LatLng?,
): ExecuteAppFunctionResult.Data {
if (location == null) {
return ExecuteAppFunctionResult.Data(
data = AppFunctionData.EMPTY,
formattedJson = "null",
)
}
val formattedJson =
JSONObject()
.apply {
put("latitude", location.latitude)
put("longitude", location.longitude)
}
.toString()
val valueType = function.response.valueType
val data =
if (valueType is AppFunctionObjectTypeMetadata) {
val locationData =
AppFunctionData.Builder(valueType, function.components)
.setDouble("latitude", location.latitude)
.setDouble("longitude", location.longitude)
.build()
AppFunctionData.Builder(function.response, function.components)
.setAppFunctionData(PROPERTY_RETURN_VALUE, locationData)
.build()
} else {
AppFunctionData.EMPTY
}
return ExecuteAppFunctionResult.Data(data = data, formattedJson = formattedJson)
}

private fun buildGeneratedImageResult(
function: AppFunctionMetadata,
result: GeneratedImageResult,
): ExecuteAppFunctionResult.Data {
val formattedJson =
JSONObject()
.apply {
put("imageUri", result.imageUri)
put("mimeType", result.mimeType)
put("prompt", result.prompt)
}
.toString()
val valueType = function.response.valueType
val data =
if (valueType is AppFunctionObjectTypeMetadata) {
val imageData =
AppFunctionData.Builder(valueType, function.components)
.setString("imageUri", result.imageUri)
.setString("mimeType", result.mimeType)
.setString("prompt", result.prompt)
.build()
AppFunctionData.Builder(function.response, function.components)
.setAppFunctionData(PROPERTY_RETURN_VALUE, imageData)
.build()
} else {
AppFunctionData.EMPTY
}
return ExecuteAppFunctionResult.Data(data = data, formattedJson = formattedJson)
}

/** Represents the latitude and longitude coordinates. */
data class LatLng(
val latitude: Double,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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.content.Context
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton

/** Helper for downloading remote files into local cache storage. */
interface FileDownloadHelper {
/**
* Downloads a remote `http://` or `https://` file at [url] into the local cache directory and
* returns its `content://` URI string.
*/
suspend fun downloadToLocalUri(url: String): String
}

/** Default [FileDownloadHelper] implementation backed by [HttpURLConnection] and [FileProvider]. */
@Singleton
class FileDownloadHelperImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
) : FileDownloadHelper {
override suspend fun downloadToLocalUri(url: String): String =
withContext(Dispatchers.IO) {
val parsedUrl = URL(url)
val connection =
(parsedUrl.openConnection() as HttpURLConnection).apply {
connectTimeout = 10000
readTimeout = 15000
}
try {
connection.connect()
val contentType = connection.contentType ?: ""
val ext =
when {
contentType.contains("png", ignoreCase = true) -> "png"
contentType.contains("jpeg", ignoreCase = true) ||
contentType.contains("jpg", ignoreCase = true) -> "jpg"
contentType.contains("gif", ignoreCase = true) -> "gif"
contentType.contains("webp", ignoreCase = true) -> "webp"
url.substringAfterLast("/", "").contains(".") ->
url.substringAfterLast("/").substringAfterLast(".")
else -> "jpg"
}
val cacheDir = File(context.cacheDir, "file_references").apply { mkdirs() }
val file = File(cacheDir, "generated_${UUID.randomUUID()}.$ext")
connection.inputStream.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
val contentUri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
contentUri.toString()
} finally {
connection.disconnect()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.example.appfunctions.agent.data.ChatRepository
import com.example.appfunctions.agent.data.DataStoreSettingsRepository
import com.example.appfunctions.agent.data.FileDownloadHelper
import com.example.appfunctions.agent.data.FileDownloadHelperImpl
import com.example.appfunctions.agent.data.InMemoryPendingIntentRepository
import com.example.appfunctions.agent.data.PendingIntentRepository
import com.example.appfunctions.agent.data.RoomChatRepository
Expand Down Expand Up @@ -66,6 +68,10 @@ abstract class DataModule {
impl: com.example.appfunctions.agent.data.LlmProviderFactoryImpl,
): com.example.appfunctions.agent.domain.LlmProviderFactory

@Binds
@Singleton
abstract fun bindFileDownloadHelper(impl: FileDownloadHelperImpl): FileDownloadHelper

companion object {
@Provides
@Singleton
Expand Down
Loading
Loading