diff --git a/agent/app/build.gradle.kts b/agent/app/build.gradle.kts index 08ef816..b195067 100644 --- a/agent/app/build.gradle.kts +++ b/agent/app/build.gradle.kts @@ -102,6 +102,7 @@ android { lint { disable += "Instantiatable" + fatal += "RestrictedApi" } } diff --git a/agent/app/src/androidTest/java/com/example/appfunctions/agent/domain/GetAppFunctionStatesUseCaseTest.kt b/agent/app/src/androidTest/java/com/example/appfunctions/agent/domain/GetAppFunctionStatesUseCaseTest.kt new file mode 100644 index 0000000..fb98fa7 --- /dev/null +++ b/agent/app/src/androidTest/java/com/example/appfunctions/agent/domain/GetAppFunctionStatesUseCaseTest.kt @@ -0,0 +1,65 @@ +/* + * 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.domain + +import android.content.Context +import androidx.appfunctions.AppFunctionManager +import androidx.appfunctions.metadata.AppFunctionName +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.FakeAppFunctionService +import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionStatesUseCase +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GetAppFunctionStatesUseCaseTest { + private lateinit var context: Context + private lateinit var appFunctionManager: AppFunctionManager + private lateinit var useCase: GetAppFunctionStatesUseCase + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + appFunctionManager = AppFunctionManager.getInstance(context)!! + useCase = GetAppFunctionStatesUseCase(appFunctionManager) + + // Adopt shell permission identity + InstrumentationRegistry.getInstrumentation() + .uiAutomation + .adoptShellPermissionIdentity("android.permission.EXECUTE_APP_FUNCTIONS") + } + + @Test + fun invoke_returnsFakeFunctionState() = + runBlocking { + val targetFunctionName = + AppFunctionName( + context.packageName, + FakeAppFunctionService.FUNCTION_ID_FAKE_FUNCTION, + ) + + val result = useCase(listOf(targetFunctionName)) + + val found = result.find { it.functionName == targetFunctionName } + assertTrue("Should find fakeFunction from $targetFunctionName", found != null) + assertTrue("The function should be enabled", checkNotNull(found).isEnabled) + } +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/data/AgentInternalTools.kt b/agent/app/src/main/java/com/example/appfunctions/agent/data/AgentInternalTools.kt index df6cf8a..bb37f1a 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/data/AgentInternalTools.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/data/AgentInternalTools.kt @@ -27,7 +27,9 @@ import androidx.annotation.RequiresApi import androidx.appfunctions.metadata.AppFunctionComponentsMetadata import androidx.appfunctions.metadata.AppFunctionDoubleTypeMetadata import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName import androidx.appfunctions.metadata.AppFunctionObjectTypeMetadata +import androidx.appfunctions.metadata.AppFunctionPackageMetadata import androidx.appfunctions.metadata.AppFunctionParameterMetadata import androidx.appfunctions.metadata.AppFunctionResponseMetadata import androidx.appfunctions.metadata.AppFunctionStringTypeMetadata @@ -49,6 +51,7 @@ import java.net.URL import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.emptyList import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -373,26 +376,27 @@ class AgentInternalTools val getCurrentLocationTool = AppFunctionMetadata( - id = "getCurrentLocation", - packageName = INTERNAL_TOOL_PACKAGE, - isEnabled = true, + name = AppFunctionName(INTERNAL_TOOL_PACKAGE, "getCurrentLocation"), schema = null, - parameters = emptyList(), + parameters = emptyList(), response = AppFunctionResponseMetadata( valueType = latLngType, description = "The current location coordinates of the device, or null.", ), - components = AppFunctionComponentsMetadata(emptyMap()), description = "Retrieve the current latitude and longitude coordinates of the device.", deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = INTERNAL_TOOL_PACKAGE, + appFunctions = listOf(), + components = AppFunctionComponentsMetadata(), + ), ) val geocodeAddressTool = AppFunctionMetadata( - id = "geocodeAddress", - packageName = INTERNAL_TOOL_PACKAGE, - isEnabled = true, + name = AppFunctionName(INTERNAL_TOOL_PACKAGE, "geocodeAddress"), schema = null, parameters = listOf( @@ -408,16 +412,19 @@ class AgentInternalTools valueType = latLngType, description = "The latitude and longitude coordinates of the address, or null.", ), - components = AppFunctionComponentsMetadata(emptyMap()), description = "Geocode a physical address string into its latitude and longitude coordinates.", deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = INTERNAL_TOOL_PACKAGE, + appFunctions = listOf(), + components = AppFunctionComponentsMetadata(), + ), ) val generateImageTool = AppFunctionMetadata( - id = "generateImage", - packageName = INTERNAL_TOOL_PACKAGE, - isEnabled = true, + name = AppFunctionName(INTERNAL_TOOL_PACKAGE, "generateImage"), schema = null, parameters = listOf( @@ -439,9 +446,14 @@ class AgentInternalTools valueType = imageResultType, description = "A GeneratedImageResult containing the generated remote image URI.", ), - components = AppFunctionComponentsMetadata(emptyMap()), description = "Generates an image from a text prompt and returns the remote image URI.", deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = INTERNAL_TOOL_PACKAGE, + appFunctions = listOf(), + components = AppFunctionComponentsMetadata(), + ), ) return listOf(getCurrentLocationTool, geocodeAddressTool, generateImageTool) 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 5e3b04d..57507f8 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 @@ -39,6 +39,7 @@ import com.example.appfunctions.agent.domain.appfunction.AppFunctionExceptionFor 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.GetAppFunctionStatesUseCase import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase import com.example.appfunctions.agent.domain.chat.ManageThreadsUseCase import com.example.appfunctions.agent.domain.chat.ObservePendingMessagesUseCase @@ -82,6 +83,7 @@ class AgentOrchestrator private val llmProviderFactory: LlmProviderFactory, private val settingsRepository: SettingsRepository, private val getAppFunctionsUseCase: GetAppFunctionsUseCase, + private val getAppFunctionStatesUseCase: GetAppFunctionStatesUseCase, private val convertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase, private val executeAppFunctionUseCase: ExecuteAppFunctionUseCase, private val savePendingIntentUseCase: SavePendingIntentUseCase, @@ -160,14 +162,23 @@ class AgentOrchestrator } } - private fun filterTools( + private suspend fun filterTools( allTools: List, disconnectedApps: Set, targetPackageName: String?, ): List { + val functionNames = allTools.map { it.name } + val states = getAppFunctionStatesUseCase(functionNames).associateBy { it.functionName } + return allTools .filter { metadata -> - metadata.isEnabled && + val isEnabled = + if (metadata.packageName == AgentInternalTools.INTERNAL_TOOL_PACKAGE) { + true + } else { + states[metadata.name]?.isEnabled ?: false + } + isEnabled && metadata.packageName !in disconnectedApps && (targetPackageName == null || metadata.packageName == targetPackageName) } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionStatesUseCase.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionStatesUseCase.kt new file mode 100644 index 0000000..7959866 --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionStatesUseCase.kt @@ -0,0 +1,33 @@ +/* + * 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.domain.appfunction + +import androidx.appfunctions.AppFunctionManager +import androidx.appfunctions.AppFunctionState +import androidx.appfunctions.metadata.AppFunctionName +import javax.inject.Inject + +/** Use case to get [AppFunctionState] with given list of [AppFunctionName]. */ +class GetAppFunctionStatesUseCase + @Inject + constructor(private val appFunctionManager: AppFunctionManager?) { + suspend operator fun invoke(functionNames: List): List { + if (appFunctionManager == null) { + return emptyList() + } + return appFunctionManager.getAppFunctionStates(functionNames) + } + } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionsUseCase.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionsUseCase.kt index 91f9af9..fc35cbc 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionsUseCase.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/GetAppFunctionsUseCase.kt @@ -19,10 +19,16 @@ import androidx.appfunctions.AppFunctionManager import androidx.appfunctions.AppFunctionSearchSpec import androidx.appfunctions.metadata.AppFunctionMetadata import androidx.appfunctions.metadata.AppFunctionPackageMetadata +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds /** Use case to get all available AppFunctions grouped by package name. */ class GetAppFunctionsUseCase @@ -35,14 +41,26 @@ class GetAppFunctionsUseCase * * @return A Flow emitting a map of package names to their list of AppFunctionMetadata. */ + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) operator fun invoke(): Flow>> { if (appFunctionManager == null) { return flowOf(emptyMap()) } - // AppFunctionSearchSpec without filters searches all visible functions - val searchSpec = AppFunctionSearchSpec() - return appFunctionManager.observeAppFunctions(searchSpec).map { packageMetadataList -> - packageMetadataList.associateWith { it.appFunctions } - } + return appFunctionManager.observeAppFunctions() + .debounce(500.milliseconds) + .flatMapLatest { _ -> + flow { + emit(appFunctionManager.search()) + } + } + .onStart { + emit(appFunctionManager.search()) + } + } + + private suspend fun AppFunctionManager.search(): Map> { + return searchAppFunctions(AppFunctionSearchSpec()).groupBy( + AppFunctionMetadata::packageMetadata, + ) } } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt index dadf8f8..384c856 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/AppFunctionItem.kt @@ -18,6 +18,8 @@ package com.example.appfunctions.agent.ui.screens.debugging import androidx.appfunctions.metadata.AppFunctionComponentsMetadata import androidx.appfunctions.metadata.AppFunctionDataTypeMetadata import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName +import androidx.appfunctions.metadata.AppFunctionPackageMetadata import androidx.appfunctions.metadata.AppFunctionParameterMetadata import androidx.appfunctions.metadata.AppFunctionResponseMetadata import androidx.appfunctions.metadata.AppFunctionStringTypeMetadata @@ -75,6 +77,7 @@ import com.example.appfunctions.agent.ui.theme.GoogleSansCodeFontFamily @Composable fun AppFunctionItem( function: AppFunctionMetadata, + isEnabled: Boolean, expanded: Boolean, inputValues: Map, onExpandedChange: (Boolean) -> Unit, @@ -111,9 +114,9 @@ fun AppFunctionItem( Row( modifier = Modifier.fillMaxWidth() - .alpha(if (function.isEnabled) 1f else 0.6f) + .alpha(if (isEnabled) 1f else 0.6f) .clickable( - enabled = function.isEnabled, + enabled = isEnabled, interactionSource = interactionSource, indication = null, ) { @@ -153,7 +156,7 @@ fun AppFunctionItem( modifier = Modifier.weight(1f, fill = false), ) - if (!function.isEnabled) { + if (!isEnabled) { Surface( color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall, @@ -195,7 +198,7 @@ fun AppFunctionItem( Box( modifier = Modifier.padding(end = 2.dp).size(48.dp).clip(CircleShape).clickable( - enabled = function.isEnabled, + enabled = isEnabled, interactionSource = interactionSource, indication = ripple(), ) { @@ -230,7 +233,7 @@ fun AppFunctionItem( // Parameters List Column( - modifier = Modifier.alpha(if (function.isEnabled) 1f else 0.6f), + modifier = Modifier.alpha(if (isEnabled) 1f else 0.6f), verticalArrangement = Arrangement.spacedBy(8.dp), ) { for (parameter in function.parameters) { @@ -259,7 +262,7 @@ fun AppFunctionItem( onClick = { onInvoke(inputValues) }, modifier = Modifier.height(48.dp).fillMaxWidth(), colors = ButtonDefaults.buttonColors(), - enabled = function.isEnabled, + enabled = isEnabled, ) { Icon( imageVector = Icons.Default.PlayArrow, @@ -299,13 +302,13 @@ private fun ParameterInput( @Preview(showBackground = true) @Composable fun AppFunctionItemPreview() { + val functionName = AppFunctionName("com.example.test", "testFunction") val stringType = AppFunctionStringTypeMetadata(isNullable = false) val response = AppFunctionResponseMetadata( valueType = stringType, description = "Returns a string", ) - val components = AppFunctionComponentsMetadata(emptyMap()) val parameter = AppFunctionParameterMetadata( name = "param1", @@ -316,19 +319,22 @@ fun AppFunctionItemPreview() { val fakeMetadata = AppFunctionMetadata( - id = "testFunction", - packageName = "com.example.test", - isEnabled = true, + name = functionName, schema = null, parameters = listOf(parameter, parameter, parameter), response = response, - components = components, description = "Test function description", deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = "com.example.test", + appFunctions = listOf(), + ), ) AppFunctionItem( function = fakeMetadata, + isEnabled = false, expanded = true, inputValues = emptyMap(), onExpandedChange = {}, @@ -340,29 +346,32 @@ fun AppFunctionItemPreview() { @Preview(showBackground = true) @Composable fun AppFunctionItem_NoParams_Preview() { + val functionName = AppFunctionName("com.example.test", "testFunction") val stringType = AppFunctionStringTypeMetadata(isNullable = false) val response = AppFunctionResponseMetadata( valueType = stringType, description = "Returns a string", ) - val components = AppFunctionComponentsMetadata(emptyMap()) val fakeMetadata = AppFunctionMetadata( - id = "testFunction", - packageName = "com.example.test", - isEnabled = true, + name = functionName, schema = null, parameters = emptyList(), response = response, - components = components, description = "Test function description", deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = "com.example.test", + appFunctions = listOf(), + ), ) AppFunctionItem( function = fakeMetadata, + isEnabled = true, expanded = true, inputValues = emptyMap(), onExpandedChange = {}, diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt index 5eb99bf..89205ba 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingUiState.kt @@ -16,6 +16,7 @@ package com.example.appfunctions.agent.ui.screens.debugging import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName import com.example.appfunctions.agent.domain.appfunction.AppInfo import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionResult import com.example.appfunctions.agent.domain.troubleshoot.TroubleshootReport @@ -40,6 +41,7 @@ sealed class SearchAppResultState { data class FunctionsFoundState( val functions: List = emptyList(), + val enabledState: Map = emptyMap(), val functionInputs: Map> = emptyMap(), val executionResult: ExecuteAppFunctionResult? = null, val expandedFunctions: Set = emptySet(), diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt index 5679fd6..69e3e52 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModel.kt @@ -17,7 +17,10 @@ package com.example.appfunctions.agent.ui.screens.debugging import android.app.PendingIntent import android.content.res.Resources +import android.util.Log +import androidx.appfunctions.AppFunctionState import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName import androidx.appfunctions.metadata.AppFunctionPackageMetadata import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,6 +30,7 @@ import com.example.appfunctions.agent.domain.appfunction.AppInfo 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.GetAppFunctionStatesUseCase import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase import com.example.appfunctions.agent.domain.appfunction.GetInstalledAppsUseCase import com.example.appfunctions.agent.domain.pendingintent.LaunchPendingIntentUseCase @@ -52,6 +56,7 @@ class DebuggingViewModel @Inject constructor( private val getAppFunctionsUseCase: GetAppFunctionsUseCase, + private val getAppFunctionStatesUseCase: GetAppFunctionStatesUseCase, private val convertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase, private val executeAppFunctionUseCase: ExecuteAppFunctionUseCase, private val getInstalledAppsUseCase: GetInstalledAppsUseCase, @@ -67,6 +72,7 @@ class DebuggingViewModel private var pinnedPackages: Set = emptySet() private var allAppFunctions: Map> = emptyMap() + private var allAppFunctionStates: Map = emptyMap() init { loadInstalledApps() @@ -87,7 +93,10 @@ class DebuggingViewModel viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } getAppFunctionsUseCase().collect { appFunctionsMap -> + val allFunctionNames = appFunctionsMap.values.flatten().map { it.name } + val appFunctionStates = getAppFunctionStatesUseCase(allFunctionNames) allAppFunctions = appFunctionsMap + allAppFunctionStates = appFunctionStates.associateBy { it.functionName } updateAppsGroupState() } } @@ -114,11 +123,25 @@ class DebuggingViewModel if (functions == null) { runTroubleshooting(appInfo.packageName) } else { + val enabledStates = + buildMap { + for (function in functions) { + val enabledState = allAppFunctionStates[function.name] + if (enabledState == null) { + Log.w(TAG, "Unable to find enabled state for ${function.name}") + } else { + put(function.name, enabledState.isEnabled) + } + } + } _uiState.update { state -> state.copy( selectedApp = appInfo, searchAppResultState = - SearchAppResultState.FunctionsFoundState(functions = functions), + SearchAppResultState.FunctionsFoundState( + functions = functions, + enabledState = enabledStates, + ), ) } } @@ -353,4 +376,8 @@ class DebuggingViewModel } return AppsGroupState(sections = sections) } + + private companion object { + const val TAG = "DebuggingViewModel" + } } diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/FunctionsFoundContent.kt b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/FunctionsFoundContent.kt index ca95ccb..483ce7a 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/FunctionsFoundContent.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/ui/screens/debugging/FunctionsFoundContent.kt @@ -18,6 +18,10 @@ package com.example.appfunctions.agent.ui.screens.debugging import android.app.PendingIntent import android.widget.Toast import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName +import androidx.appfunctions.metadata.AppFunctionPackageMetadata +import androidx.appfunctions.metadata.AppFunctionResponseMetadata +import androidx.appfunctions.metadata.AppFunctionStringTypeMetadata import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,6 +42,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Error import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -93,11 +98,17 @@ fun FunctionsFoundContent( items = state.functions, key = { function -> function.id }, ) { function -> + val isEnabled = state.enabledState[function.name] + if (isEnabled == null) { + AppFunctionErrorItem(function = function) + return@items + } val expanded = state.expandedFunctions.contains(function.id) val inputValues = state.functionInputs[function.id] ?: emptyMap() AppFunctionItem( function = function, + isEnabled = isEnabled, expanded = expanded, inputValues = inputValues, onExpandedChange = { isExpanded -> @@ -249,12 +260,124 @@ fun FunctionsFoundContent( } } +@Composable +fun AppFunctionErrorItem( + function: AppFunctionMetadata, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + tonalElevation = 2.dp, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Column { + val hashIndex = function.id.indexOf('#') + val name = if (hashIndex != -1) function.id.substring(hashIndex + 1) else function.id + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = "Unable to determine enabled state", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun AppFunctionErrorItemPreview() { + val functionName = AppFunctionName("com.example.test", "testFunction") + val stringType = AppFunctionStringTypeMetadata(isNullable = false) + val response = + AppFunctionResponseMetadata( + valueType = stringType, + description = "Returns a string", + ) + val fakeMetadata = + AppFunctionMetadata( + name = functionName, + schema = null, + parameters = emptyList(), + response = response, + description = "Test function description", + deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = "com.example.test", + appFunctions = listOf(), + components = androidx.appfunctions.metadata.AppFunctionComponentsMetadata(), + ), + ) + AppFunctionsAgentTheme { + AppFunctionErrorItem(function = fakeMetadata) + } +} + @Preview(showBackground = true) @Composable fun FunctionsFoundContentPreview() { + val stringType = AppFunctionStringTypeMetadata(isNullable = false) + val response = + AppFunctionResponseMetadata( + valueType = stringType, + description = "Returns a string", + ) + + val function1Name = AppFunctionName("com.example.test", "normalFunction") + val function1 = + AppFunctionMetadata( + name = function1Name, + schema = null, + parameters = emptyList(), + response = response, + description = "A normal function", + deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = "com.example.test", + appFunctions = listOf(), + components = androidx.appfunctions.metadata.AppFunctionComponentsMetadata(), + ), + ) + + val function2Name = AppFunctionName("com.example.test", "errorFunction") + val function2 = + AppFunctionMetadata( + name = function2Name, + schema = null, + parameters = emptyList(), + response = response, + description = "A function with error state", + deprecation = null, + packageMetadata = + AppFunctionPackageMetadata( + packageName = "com.example.test", + appFunctions = listOf(), + components = androidx.appfunctions.metadata.AppFunctionComponentsMetadata(), + ), + ) + val dummyState = SearchAppResultState.FunctionsFoundState( - functions = emptyList(), + functions = listOf(function1, function2), + enabledState = mapOf(function1Name to true), expandedFunctions = emptySet(), functionInputs = emptyMap(), executionResult = null, 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 93350b7..f37846e 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 @@ -17,7 +17,9 @@ package com.example.appfunctions.agent.domain import android.content.Intent import androidx.appfunctions.AppFunctionData +import androidx.appfunctions.AppFunctionState import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName import androidx.appfunctions.metadata.AppFunctionPackageMetadata import com.example.appfunctions.agent.data.AgentInternalTools import com.example.appfunctions.agent.data.LlmModel @@ -32,6 +34,7 @@ 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.GetAppFunctionStatesUseCase import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase import com.example.appfunctions.agent.domain.chat.ManageThreadsUseCase import com.example.appfunctions.agent.domain.chat.ObservePendingMessagesUseCase @@ -66,6 +69,7 @@ class AgentOrchestratorTest { private val settingsRepository: SettingsRepository = mockk() private val llmProviderFactory: LlmProviderFactory = mockk() private val getAppFunctionsUseCase: GetAppFunctionsUseCase = mockk() + private val getAppFunctionStatesUseCase: GetAppFunctionStatesUseCase = mockk() private val executeAppFunctionUseCase: ExecuteAppFunctionUseCase = mockk() private val sendMessageUseCase: SendMessageUseCase = mockk(relaxed = true) private val convertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase = mockk() @@ -88,11 +92,22 @@ class AgentOrchestratorTest { llmProviderFactory = llmProviderFactory, settingsRepository = settingsRepository, getAppFunctionsUseCase = getAppFunctionsUseCase, + getAppFunctionStatesUseCase = getAppFunctionStatesUseCase, convertInputToAppFunctionDataUseCase = convertInputToAppFunctionDataUseCase, executeAppFunctionUseCase = executeAppFunctionUseCase, savePendingIntentUseCase = savePendingIntentUseCase, agentInternalTools = agentInternalTools, ) + + coEvery { getAppFunctionStatesUseCase(any()) } answers { + val names = firstArg>() + names.map { name -> + val state = mockk() + every { state.functionName } returns name + every { state.isEnabled } returns true + state + } + } } @Test @@ -367,6 +382,7 @@ class AgentOrchestratorTest { val tool = mockk(relaxed = true) every { tool.packageName } returns packageName every { tool.id } returns id + every { tool.name } returns AppFunctionName(packageName, id) every { tool.isEnabled } returns isEnabled return tool } @@ -530,4 +546,45 @@ class AgentOrchestratorTest { formattedJson = toolResultJson, ) } + + @Test + fun `observeAndProcessMessages filters out disabled tools`() = + runTest { + val threadId = "thread_1" + val message = createUserMessage(threadId, "run geo code address for n1c4ag") + val thread = createThread(threadId) + val llmProvider = mockk() + + val enabledTool = createMockTool("com.google.android.appfunctiontestingagent", "enabled_tool") + val disabledTool = createMockTool("com.google.android.appfunctiontestingagent", "disabled_tool") + mockAppFunctions(listOf(enabledTool, disabledTool)) + + setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider) + + coEvery { getAppFunctionStatesUseCase(any()) } answers { + val names = firstArg>() + names.map { name -> + val state = mockk() + every { state.functionName } returns name + every { state.isEnabled } returns (name.toString().contains("enabled_tool")) + state + } + } + + coEvery { + llmProvider.generateResponse(any(), any(), any(), any(), any(), any()) + } returns LlmResponse.Success("interaction_id", listOf(LlmResponsePart.Text("Success"))) + + agentOrchestrator.observeAndProcessMessages(threadId) + + coVerify { + llmProvider.generateResponse( + previousInteractionId = null, + input = eq(LlmInput.UserMessage("run geo code address for n1c4ag")), + tools = listOf(enabledTool), + apiKey = "dummy_key", + modelName = any(), + ) + } + } } diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt index fa4237c..b07495f 100644 --- a/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt +++ b/agent/app/src/test/java/com/example/appfunctions/agent/ui/screens/debugging/DebuggingViewModelTest.kt @@ -21,7 +21,9 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.graphics.Color import android.graphics.drawable.ColorDrawable +import androidx.appfunctions.AppFunctionState import androidx.appfunctions.metadata.AppFunctionMetadata +import androidx.appfunctions.metadata.AppFunctionName import androidx.appfunctions.metadata.AppFunctionPackageMetadata import androidx.test.core.app.ApplicationProvider import com.example.appfunctions.agent.R @@ -29,10 +31,12 @@ import com.example.appfunctions.agent.data.SettingsRepository import com.example.appfunctions.agent.domain.appfunction.AppInfo import com.example.appfunctions.agent.domain.appfunction.ConvertInputToAppFunctionDataUseCase import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionUseCase +import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionStatesUseCase import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase import com.example.appfunctions.agent.domain.appfunction.GetInstalledAppsUseCase import com.example.appfunctions.agent.domain.pendingintent.LaunchPendingIntentUseCase import com.example.appfunctions.agent.domain.troubleshoot.TroubleshootAppUseCase +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers @@ -60,6 +64,7 @@ class DebuggingViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockGetAppFunctionsUseCase: GetAppFunctionsUseCase + private lateinit var mockGetAppFunctionStatesUseCase: GetAppFunctionStatesUseCase private lateinit var mockConvertInputToAppFunctionDataUseCase: ConvertInputToAppFunctionDataUseCase private lateinit var mockExecuteAppFunctionUseCase: ExecuteAppFunctionUseCase @@ -74,6 +79,7 @@ class DebuggingViewModelTest { fun setup() { Dispatchers.setMain(testDispatcher) mockGetAppFunctionsUseCase = mockk() + mockGetAppFunctionStatesUseCase = mockk() mockConvertInputToAppFunctionDataUseCase = mockk() mockExecuteAppFunctionUseCase = mockk() mockGetInstalledAppsUseCase = mockk() @@ -82,6 +88,15 @@ class DebuggingViewModelTest { mockSettingsRepository = mockk() context = ApplicationProvider.getApplicationContext() + coEvery { mockGetAppFunctionStatesUseCase(any()) } answers { + val names = firstArg>() + names.map { name -> + val state = mockk() + every { state.functionName } returns name + every { state.isEnabled } returns true + state + } + } every { mockGetInstalledAppsUseCase() } returns emptyList() every { mockSettingsRepository.pinnedApps } returns flowOf(emptySet()) } @@ -94,7 +109,11 @@ class DebuggingViewModelTest { @Test fun `initial state loads apps`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata = mockk() every { mockPackageMetadata.packageName } returns "com.example.app" val expectedAppInfo = AppInfo("com.example.app", "com.example.app", null) @@ -105,6 +124,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -125,7 +145,11 @@ class DebuggingViewModelTest { @Test fun `initial state loads apps with resolved metadata`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata = mockk() val packageName = "com.example.app.resolved" every { mockPackageMetadata.packageName } returns packageName @@ -156,6 +180,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -182,7 +207,11 @@ class DebuggingViewModelTest { @Test fun `onSearchQueryChanged filters apps`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata1 = mockk() every { mockPackageMetadata1.packageName } returns "com.example.app1" val mockPackageMetadata2 = mockk() @@ -202,6 +231,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -224,7 +254,11 @@ class DebuggingViewModelTest { @Test fun `onAppSelected updates selected app and functions`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata = mockk() every { mockPackageMetadata.packageName } returns "com.example.app" every { mockGetAppFunctionsUseCase() } returns @@ -233,6 +267,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -255,7 +290,11 @@ class DebuggingViewModelTest { @Test fun `onClearSelectedApp clears selected app and functions`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata = mockk() every { mockPackageMetadata.packageName } returns "com.example.app" every { mockGetAppFunctionsUseCase() } returns @@ -264,6 +303,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -294,7 +334,11 @@ class DebuggingViewModelTest { @Test fun `onFunctionInputsChange updates state`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata = mockk() every { mockPackageMetadata.packageName } returns "com.example.app" every { mockGetAppFunctionsUseCase() } returns @@ -303,6 +347,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -328,7 +373,11 @@ class DebuggingViewModelTest { @Test fun `launchPendingIntent calls use case and clears result on success`() = runTest { - val mockMetadata = mockk() + val mockMetadata = + mockk(relaxed = true) { + every { name } returns AppFunctionName("com.example.app", "test_function") + every { id } returns "test_function" + } val mockPackageMetadata = mockk() every { mockPackageMetadata.packageName } returns "com.example.app" every { mockGetAppFunctionsUseCase() } returns @@ -340,6 +389,7 @@ class DebuggingViewModelTest { viewModel = DebuggingViewModel( mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, mockConvertInputToAppFunctionDataUseCase, mockExecuteAppFunctionUseCase, mockGetInstalledAppsUseCase, @@ -360,4 +410,81 @@ class DebuggingViewModelTest { val functionsState = state.searchAppResultState as SearchAppResultState.FunctionsFoundState assertEquals(null, functionsState.executionResult) } + + @Test + fun `onAppSelected populates enabled states from use case`() = + runTest { + val enabledFunctionName = AppFunctionName("com.example.app", "enabled_function") + val disabledFunctionName = AppFunctionName("com.example.app", "disabled_function") + val missingFunctionName = AppFunctionName("com.example.app", "missing_function") + + val mockEnabledMetadata = + mockk(relaxed = true) { + every { name } returns enabledFunctionName + every { id } returns "enabled_function" + } + val mockDisabledMetadata = + mockk(relaxed = true) { + every { name } returns disabledFunctionName + every { id } returns "disabled_function" + } + val mockMissingMetadata = + mockk(relaxed = true) { + every { name } returns missingFunctionName + every { id } returns "missing_function" + } + + val mockPackageMetadata = mockk() + every { mockPackageMetadata.packageName } returns "com.example.app" + every { mockGetAppFunctionsUseCase() } returns + flowOf( + mapOf( + mockPackageMetadata to + listOf(mockEnabledMetadata, mockDisabledMetadata, mockMissingMetadata), + ), + ) + + coEvery { mockGetAppFunctionStatesUseCase(any()) } answers { + val names = firstArg>() + names.mapNotNull { name -> + when (name) { + enabledFunctionName -> + mockk { + every { functionName } returns name + every { isEnabled } returns true + } + disabledFunctionName -> + mockk { + every { functionName } returns name + every { isEnabled } returns false + } + else -> null + } + } + } + + viewModel = + DebuggingViewModel( + mockGetAppFunctionsUseCase, + mockGetAppFunctionStatesUseCase, + mockConvertInputToAppFunctionDataUseCase, + mockExecuteAppFunctionUseCase, + mockGetInstalledAppsUseCase, + mockTroubleshootAppUseCase, + mockLaunchPendingIntentUseCase, + mockSettingsRepository, + context, + ) + advanceUntilIdle() + + viewModel.onAppSelected(AppInfo("com.example.app", "com.example.app", null)) + + val state = viewModel.uiState.value + val functionsState = state.searchAppResultState as SearchAppResultState.FunctionsFoundState + + assertEquals( + mapOf(enabledFunctionName to true, disabledFunctionName to false), + functionsState.enabledState, + ) + } } diff --git a/agent/gradle/libs.versions.toml b/agent/gradle/libs.versions.toml index 94cc165..af7e443 100644 --- a/agent/gradle/libs.versions.toml +++ b/agent/gradle/libs.versions.toml @@ -20,7 +20,7 @@ mockk = "1.14.11" ksp = "2.3.10" hilt = "2.60.1" androidxHiltNavigationCompose = "1.4.0" -appfunctions = "1.0.0-alpha10" +appfunctions = "1.0.0-SNAPSHOT" datastore = "1.2.1" screenshot = "0.0.1-alpha15" coil = "2.7.0" diff --git a/agent/settings.gradle.kts b/agent/settings.gradle.kts index c422771..6dc753d 100644 --- a/agent/settings.gradle.kts +++ b/agent/settings.gradle.kts @@ -24,6 +24,9 @@ pluginManagement { } mavenCentral() gradlePluginPortal() + maven { + url = uri("https://androidx.dev/snapshots/builds/16120018/artifacts/repository") + } } } @@ -32,6 +35,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { + url = uri("https://androidx.dev/snapshots/builds/16120018/artifacts/repository") + } } }