Skip to content
Open
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
1 change: 1 addition & 0 deletions agent/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ android {

lint {
disable += "Instantiatable"
fatal += "RestrictedApi"
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<AppFunctionParameterMetadata>(),
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(
Expand All @@ -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(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -160,14 +162,23 @@ class AgentOrchestrator
}
}

private fun filterTools(
private suspend fun filterTools(
allTools: List<AppFunctionMetadata>,
disconnectedApps: Set<String>,
targetPackageName: String?,
): List<AppFunctionMetadata> {
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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AppFunctionName>): List<AppFunctionState> {
if (appFunctionManager == null) {
return emptyList()
}
return appFunctionManager.getAppFunctionStates(functionNames)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Map<AppFunctionPackageMetadata, List<AppFunctionMetadata>>> {
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<AppFunctionPackageMetadata, List<AppFunctionMetadata>> {
return searchAppFunctions(AppFunctionSearchSpec()).groupBy(
AppFunctionMetadata::packageMetadata,
)
}
}
Loading