diff --git a/ChatApp/app/build.gradle.kts b/ChatApp/app/build.gradle.kts index 1015a43..040e35e 100644 --- a/ChatApp/app/build.gradle.kts +++ b/ChatApp/app/build.gradle.kts @@ -71,7 +71,6 @@ dependencies { // App functions implementation(libs.androidx.appfunctions) - implementation(libs.androidx.appfunctions.service) ksp(libs.androidx.appfunctions.compiler) testImplementation(libs.junit) diff --git a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/AppFunctionsViewModelTest.kt b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/AppFunctionsViewModelTest.kt index 974cabf..ef88d06 100644 --- a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/AppFunctionsViewModelTest.kt +++ b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/AppFunctionsViewModelTest.kt @@ -47,7 +47,7 @@ class AppFunctionsViewModelTest { fun toggleFunction_doesNotCrash() = runTest { // Use a potentially real ID from the app - viewModel.toggleFunction("com.example.chatapp.appfunctions.AppFunctions#searchContacts", true) + viewModel.toggleFunction(com.example.chatapp.appfunctions.AppFunctionsIds.SEARCH_CONTACTS_ID, true) } @Test diff --git a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionInstrumentationTest.kt b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionInstrumentationTest.kt index b88194f..b071e12 100644 --- a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionInstrumentationTest.kt +++ b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionInstrumentationTest.kt @@ -51,8 +51,6 @@ class AppFunctionInstrumentationTest { @Inject lateinit var recipientsRepository: RecipientsRepository - @Inject lateinit var appFunctions: AppFunctions - private val context: Context = ApplicationProvider.getApplicationContext() private val appFunctionManager: AppFunctionManager = checkNotNull(AppFunctionManager.getInstance(context)) @@ -164,10 +162,10 @@ class AppFunctionInstrumentationTest { .getAppFunctionDataList( ExecuteAppFunctionResponse.Success.PROPERTY_RETURN_VALUE, ) - ?.map { it.deserialize(AppFunctions.ContactSearchResult::class.java) }, + ?.map { it.deserialize(ContactSearchResult::class.java) }, ) .containsExactly( - AppFunctions.ContactSearchResult(endpointValue = "1", endpointType = "INDIVIDUAL", displayName = "Alice Smith"), + ContactSearchResult(endpointValue = "1", endpointType = "INDIVIDUAL", displayName = "Alice Smith"), ) } } diff --git a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt index d4095e3..3646a31 100644 --- a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt +++ b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt @@ -18,7 +18,6 @@ package com.example.chatapp.appfunctions import android.content.Context import android.net.Uri import androidx.appfunctions.AppFunctionAppUnknownException -import androidx.appfunctions.AppFunctionContext import androidx.appfunctions.AppFunctionElementNotFoundException import androidx.appfunctions.AppFunctionInvalidArgumentException import androidx.test.core.app.ApplicationProvider @@ -38,12 +37,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AppFunctionsTest { - private val testContext = - object : AppFunctionContext { - override val context: Context - get() = ApplicationProvider.getApplicationContext() - } - private class MockMessageRepository : MessageRepository { var shouldFail = false private val messages = MutableStateFlow>>(emptyMap()) @@ -85,19 +78,47 @@ class AppFunctionsTest { private val callManager = CallManager(recipientsRepository) - private val appFunctions = AppFunctions(messageRepository, recipientsRepository, callManager) + private class TestChatAppFunctionService( + val testContext: Context, + messageRepo: MessageRepository, + recipientsRepo: RecipientsRepository, + callMgr: CallManager, + ) : BaseChatAppFunctionService() { + init { + attachBaseContext(testContext) + this.messageRepository = messageRepo + this.recipientsRepository = recipientsRepo + this.callManager = callMgr + } + + override fun onExecuteFunction( + request: androidx.appfunctions.ExecuteAppFunctionRequest, + cancellationSignal: android.os.CancellationSignal, + callback: java.util.function.Consumer, + ) { + // Not used in direct unit tests + } + } + + private val appFunctions = + TestChatAppFunctionService( + ApplicationProvider.getApplicationContext(), + messageRepository, + recipientsRepository, + callManager, + ) @Test(expected = AppFunctionInvalidArgumentException::class) fun searchContacts_returnsEmptyList() { runBlocking { - appFunctions.searchContacts(testContext, "nonexistent", "INDIVIDUAL") + appFunctions.searchContacts("nonexistent", "INDIVIDUAL") } } @Test fun searchContacts_returnsMatches() { runBlocking { - val contacts = appFunctions.searchContacts(testContext, "Alice", "INDIVIDUAL") + val contacts = appFunctions.searchContacts("Alice", "INDIVIDUAL") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].displayName) } @@ -106,7 +127,7 @@ class AppFunctionsTest { @Test fun searchContacts_groups_returnsMatches() { runBlocking { - val contacts = appFunctions.searchContacts(testContext, "Work", "GROUP") + val contacts = appFunctions.searchContacts("Work", "GROUP") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Work Friends", contacts[0].displayName) } @@ -115,7 +136,7 @@ class AppFunctionsTest { @Test fun searchContacts_emptyQuery_returnsRecent() { runBlocking { - val contacts = appFunctions.searchContacts(testContext, "", "INDIVIDUAL") + val contacts = appFunctions.searchContacts("", "INDIVIDUAL") Assert.assertEquals(3, contacts.size) } } @@ -123,8 +144,7 @@ class AppFunctionsTest { @Test fun searchContacts_anyType_returnsMatches() { runBlocking { - // "searchAny" branch (when filterType is not INDIVIDUAL or GROUP) - val contacts = appFunctions.searchContacts(testContext, "Alice", "ANY") + val contacts = appFunctions.searchContacts("Alice", "ANY") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].displayName) } @@ -133,9 +153,9 @@ class AppFunctionsTest { @Test fun send_validMessage_returnsSuccess() { runTest { - val result = appFunctions.send(testContext, "1", "Hello") + val result = appFunctions.send("1", "Hello") Assert.assertEquals( - AppFunctions.Result( + Result( "Message ID", "Message sent to: Alice Smith.", ), @@ -149,13 +169,12 @@ class AppFunctionsTest { runTest { val result = appFunctions.send( - testContext, "1", "Hello", listOf(Uri.parse("content://media/1")), ) Assert.assertEquals( - AppFunctions.Result( + Result( "Message ID", "Message sent to: Alice Smith.", ), @@ -167,9 +186,9 @@ class AppFunctionsTest { @Test fun send_toGroup_success() { runTest { - val result = appFunctions.send(testContext, "g1", "Hello") + val result = appFunctions.send("g1", "Hello") Assert.assertEquals( - AppFunctions.Result( + Result( "Message ID", "Message sent to: Work Friends.", ), @@ -181,14 +200,14 @@ class AppFunctionsTest { @Test(expected = AppFunctionInvalidArgumentException::class) fun send_emptyContent_fails() { runTest { - appFunctions.send(testContext, "1", "") + appFunctions.send("1", "") } } @Test(expected = AppFunctionElementNotFoundException::class) fun send_invalidRecipient_fails() { runTest { - appFunctions.send(testContext, "nonexistent_id", "Hello") + appFunctions.send("nonexistent_id", "Hello") } } @@ -196,24 +215,22 @@ class AppFunctionsTest { fun send_repositoryError_returnsError() { runTest { messageRepository.shouldFail = true - appFunctions.send(testContext, "1", "Hello") + appFunctions.send("1", "Hello") } } @Test fun makeCall_returnsPendingIntent() { runBlocking { - val pendingIntent = appFunctions.makeCall(testContext, endpointValue = "1") + val pendingIntent = appFunctions.makeCall(endpointValue = "1") Assert.assertNotNull(pendingIntent) } } - @Test(expected = AppFunctionElementNotFoundException::class) fun makeCall_invalidEndpointValue_fails() { runBlocking { - appFunctions.makeCall(testContext, endpointValue = "nonexistent_id") + appFunctions.makeCall(endpointValue = "nonexistent_id") } } - } diff --git a/ChatApp/app/src/main/AndroidManifest.xml b/ChatApp/app/src/main/AndroidManifest.xml index 08d479f..5b17432 100644 --- a/ChatApp/app/src/main/AndroidManifest.xml +++ b/ChatApp/app/src/main/AndroidManifest.xml @@ -15,7 +15,8 @@ --> + xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions" + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + diff --git a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/CallManager.kt b/ChatApp/app/src/main/kotlin/com/example/chatapp/data/CallManager.kt deleted file mode 100644 index de76935..0000000 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/CallManager.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.chatapp.data - -import com.example.chatapp.appfunctions.AppFunctions.Recipient -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class CallManager - @Inject - constructor( - private val recipientsRepository: RecipientsRepository, - ) { - private val _activeCall = MutableStateFlow(null) - val activeCall: StateFlow = _activeCall.asStateFlow() - - fun startCall(recipient: Recipient) { - _activeCall.value = recipient - } - - fun startCall(recipientId: String) { - val recipient = - recipientsRepository.getRecipientById(recipientId) - ?: recipientsRepository.getGroupById(recipientId)?.let { - Recipient(it.id, it.name, "") - } - ?: Recipient(recipientId, recipientId, "") - startCall(recipient) - } - - fun endCall() { - _activeCall.value = null - } - } diff --git a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt b/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt deleted file mode 100644 index fdd153e..0000000 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * 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.chatapp.data - -import com.example.chatapp.appfunctions.AppFunctions.ChatGroup -import com.example.chatapp.appfunctions.AppFunctions.ContactSearchResult -import com.example.chatapp.appfunctions.AppFunctions.Recipient -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Repository responsible for managing and providing access to contact information. - * - * This class serves as a data source for [Recipient] objects, currently maintaining a static list - * of contacts and providing functionality to filter them via search queries. - */ -@Singleton -class RecipientsRepository - @Inject - constructor() { - private val recipients = - listOf( - Recipient(id = "1", name = "Alice Smith", email = "alice@example.com"), - Recipient(id = "2", name = "Bob Johnson", email = "bob@example.com"), - Recipient(id = "3", name = "Charlie Brown", email = "charlie@example.com"), - Recipient(id = "4", name = "David Wilson", email = "david@example.com"), - Recipient(id = "5", name = "Eve Davis", email = "eve@example.com"), - Recipient(id = "6", name = "Frank Miller", email = "frank@example.com"), - Recipient(id = "7", name = "Bob Johnson", email = "bob2@example.com"), - ) - - private val groups = - listOf( - ChatGroup( - id = "g1", - name = "Work Friends", - recipients = recipients.take(3), - ), - ChatGroup( - id = "g2", - name = "Family", - recipients = recipients.takeLast(2), - ), - ) - - /** - * Retrieves all available contacts. - * - * @return A list of all [Recipient] objects. - */ - fun getAllRecipients(): List { - return recipients - } - - /** - * Retrieves all available groups. - * - * @return A list of all [ChatGroup] objects. - */ - fun getAllGroups(): List { - return groups - } - - /** - * Searches for contacts that match the given query. - * - * The search is case-insensitive and matches against both the contact's name and email address. - * If the [query] is blank, return [maxCount] contacts. - * - * @param query The search term used to filter contacts. - * @param maxCount Maximum recipients to return if [query] is blank. - * @return A list of [Recipient] objects that match the search criteria. - */ - fun searchRecipients( - query: String?, - maxCount: Int, - ): List { - if (query.isNullOrBlank()) { - // TODO:Return most recently contacted. - return recipients.take(maxCount) - } - - return recipients.filter { - it.name.contains(query, ignoreCase = true) || - it.email.contains( - query, - ignoreCase = true, - ) - } - } - - /** - * Searches for groups that match the given query. - * - * The search is case-insensitive and matches against the group's name. - * If the [query] is blank, return [maxCount] groups. - * - * @param query The search term used to filter groups. - * @param maxCount Maximum groups to return if [query] is blank. - * @return A list of [ChatGroup] objects that match the search criteria. - */ - fun searchGroups( - query: String?, - maxCount: Int, - ): List { - if (query.isNullOrBlank()) { - return groups.take(maxCount) - } - - return groups.filter { - it.name.contains(query, ignoreCase = true) - } - } - - /** - * Searches for any entity (contact or group) matching the query. - * - * @param query Search string for name. - * @param maxCount Maximum number of results to return per entity type. - * @return A unified list of [ContactSearchResult] containing both individuals and groups. - */ - fun searchAny( - query: String?, - maxCount: Int, - ): List { - val individuals = - searchRecipients(query, maxCount).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - displayName = it.name, - ) - } - val groups = - searchGroups(query, maxCount).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "GROUP", - displayName = it.name, - ) - } - return mutableListOf().apply { - addAll(individuals) - addAll(groups) - } - } - - fun getRecipientById(id: String): Recipient? = recipients.singleOrNull { it.id == id } - - fun getRecipientByName(name: String): List = recipients.filter { it.name == name } - - fun getGroupById(id: String): ChatGroup? = groups.singleOrNull { it.id == id } - } diff --git a/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/RecipientsScreen.kt b/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/RecipientsScreen.kt index fbd665c..f18c55a 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/RecipientsScreen.kt +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/RecipientsScreen.kt @@ -49,8 +49,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.chatapp.RecipientsViewModel -import com.example.chatapp.appfunctions.AppFunctions.ChatGroup -import com.example.chatapp.appfunctions.AppFunctions.Recipient +import com.example.chatapp.appfunctions.ChatGroup +import com.example.chatapp.appfunctions.Recipient /** * A screen that displays a list of recipients/contacts. diff --git a/ChatApp/app/src/main/res/xml/app_metadata.xml b/ChatApp/app/src/main/res/xml/app_metadata.xml index de3cc7f..57d9673 100644 --- a/ChatApp/app/src/main/res/xml/app_metadata.xml +++ b/ChatApp/app/src/main/res/xml/app_metadata.xml @@ -14,13 +14,13 @@ ~ limitations under the License. --> - diff --git a/ChatApp/gradle/libs.versions.toml b/ChatApp/gradle/libs.versions.toml index 56bad2a..9a44bde 100644 --- a/ChatApp/gradle/libs.versions.toml +++ b/ChatApp/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.2.1" -appfunctions = "1.0.0-alpha09" +appfunctions = "1.0.0-alpha10" kotlin = "2.3.21" coreKtx = "1.18.0" junit = "4.13.2" @@ -27,7 +27,6 @@ ktlint = "1.2.1" [libraries] androidx-appfunctions = { module = "androidx.appfunctions:appfunctions", version.ref = "appfunctions" } androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" } -androidx-appfunctions-service = { module = "androidx.appfunctions:appfunctions-service", version.ref = "appfunctions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } diff --git a/ChatApp/shared/build.gradle.kts b/ChatApp/shared/build.gradle.kts index 7d93e0f..b9aba68 100644 --- a/ChatApp/shared/build.gradle.kts +++ b/ChatApp/shared/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { // App functions implementation(libs.androidx.appfunctions) - implementation(libs.androidx.appfunctions.service) ksp(libs.androidx.appfunctions.compiler) } diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/BaseChatApplication.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/BaseChatApplication.kt index 809175b..b051a26 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/BaseChatApplication.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/BaseChatApplication.kt @@ -16,27 +16,4 @@ package com.example.chatapp import android.app.Application -import androidx.appfunctions.service.AppFunctionConfiguration -import com.example.chatapp.appfunctions.AppFunctions -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent - -abstract class BaseChatApplication : Application(), AppFunctionConfiguration.Provider { - override val appFunctionConfiguration: AppFunctionConfiguration - get() { - val entryPoint = EntryPointAccessors.fromApplication(this, AppFunctionsEntryPoint::class.java) - val taskFunctions = entryPoint.getAppFunctions() - - return AppFunctionConfiguration.Builder() - .addEnclosingClassFactory(AppFunctions::class.java) { taskFunctions } - .build() - } -} - -@EntryPoint -@InstallIn(SingletonComponent::class) -interface AppFunctionsEntryPoint { - fun getAppFunctions(): AppFunctions -} +abstract class BaseChatApplication : Application() diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/ChatViewModel.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/ChatViewModel.kt index a7ec427..be30019 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/ChatViewModel.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/ChatViewModel.kt @@ -18,7 +18,7 @@ package com.example.chatapp import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.chatapp.appfunctions.AppFunctions.Recipient +import com.example.chatapp.appfunctions.Recipient import com.example.chatapp.data.CallManager import com.example.chatapp.data.DisplayMessage import com.example.chatapp.data.MessageRepository diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/RecipientsViewModel.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/RecipientsViewModel.kt index e4e8793..17462cc 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/RecipientsViewModel.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/RecipientsViewModel.kt @@ -16,8 +16,8 @@ package com.example.chatapp import androidx.lifecycle.ViewModel -import com.example.chatapp.appfunctions.AppFunctions.ChatGroup -import com.example.chatapp.appfunctions.AppFunctions.Recipient +import com.example.chatapp.appfunctions.ChatGroup +import com.example.chatapp.appfunctions.Recipient import com.example.chatapp.data.DisplayMessage import com.example.chatapp.data.MessageRepository import com.example.chatapp.data.RecipientsRepository diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt deleted file mode 100644 index 81a6574..0000000 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ /dev/null @@ -1,209 +0,0 @@ -/* - * 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.chatapp.appfunctions - -import android.app.PendingIntent -import android.content.Intent -import android.net.Uri -import androidx.appfunctions.AppFunctionAppUnknownException -import androidx.appfunctions.AppFunctionContext -import androidx.appfunctions.AppFunctionElementNotFoundException -import androidx.appfunctions.AppFunctionInvalidArgumentException -import androidx.appfunctions.AppFunctionSerializable -import androidx.appfunctions.AppFunctionStringValueConstraint -import androidx.appfunctions.service.AppFunction -import com.example.chatapp.data.CallManager -import com.example.chatapp.data.MessageRepository -import com.example.chatapp.data.RecipientsRepository -import javax.inject.Inject - -/** - * Provides functions for chat-related operations such as retrieving contacts and sending messages. - */ -class AppFunctions - @Inject - constructor( - private val messageRepository: MessageRepository, - private val recipientsRepository: RecipientsRepository, - private val callManager: CallManager, - ) { - /** - * Search for contacts or groups by name. - * - * @param appFunctionContext The context of this app function call. - * @param query Search string for the contact or group name. Can be partial or full names. - * If blank, returns the most recently contacted entities. - * @param filterType Filter results by entity type. Accepts "INDIVIDUAL" or "GROUP". - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun searchContacts( - appFunctionContext: AppFunctionContext, - query: String, - @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP"]) - filterType: String, - ): List { - val recipients = - when (filterType) { - "INDIVIDUAL" -> { - recipientsRepository.searchRecipients(query, 3).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - displayName = it.name, - ) - } - } - "GROUP" -> { - recipientsRepository.searchGroups(query, 3).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "GROUP", - displayName = it.name, - ) - } - } - else -> { - recipientsRepository.searchAny(query, maxCount = 3) - .ifEmpty { - throw AppFunctionInvalidArgumentException( - "Only INDIVIDUAL or GROUP are accepted filter arguments.", - ) - } - } - } - - if (recipients.isEmpty()) { - throw AppFunctionInvalidArgumentException( - "$filterType with name $query not found. Ask the user to clarify the name", - ) - } - return recipients - } - - /** - * Send a text message with optional image attachments. - * - * @param appFunctionContext The context of this app function call. - * @param endpointValue The unique identifier for the recipient or group. - * @param messageBody The text content of the message. Cannot be empty. - * @param imageUris List of URIs for images to attach. - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun send( - appFunctionContext: AppFunctionContext, - endpointValue: String, - messageBody: String, - imageUris: List? = null, - ): Result { - if (messageBody.isBlank()) { - throw AppFunctionInvalidArgumentException("Message body cannot be empty") - } - val displayName = - recipientsRepository.getRecipientById(endpointValue)?.name - ?: recipientsRepository.getGroupById(endpointValue)?.name - ?: throw AppFunctionElementNotFoundException( - "No contact or group found for endpointValue: $endpointValue", - ) - - val sentMessageId = - try { - messageRepository.send( - text = messageBody, - recipientIds = listOf(endpointValue), - imageUris = imageUris?.map { it.toString() }, - ) - } catch (e: Exception) { - throw AppFunctionAppUnknownException("Failed to send message: ${e.message}") - } - - // 3. RETURN: Provide a confirmation string - // The bot may use this string or the fact that it didn't throw to confirm success. - return Result(sentMessageId, "Message sent to: $displayName.") - } - - /** - * Initiate a voice call. - * - * @param appFunctionContext The context of this app function call. - * @param endpointValue The unique identifier for the recipient. - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun makeCall( - appFunctionContext: AppFunctionContext, - endpointValue: String, - ): PendingIntent { - val recipient = - recipientsRepository.getRecipientById(endpointValue) - ?: throw AppFunctionElementNotFoundException( - "No recipient exists for endpointValue: $endpointValue", - ) - - // Call manager should technically also record it here depending on app architecture, - // but we will launch the intent to handle it in the UI. - callManager.startCall(recipient.id) - - // Create a pending intent to the MainActivity, initiating the call - return PendingIntent.getActivity( - appFunctionContext.context, - 0, - Intent(appFunctionContext.context, Class.forName("com.example.chatapp.MainActivity")) - .apply { putExtra("nav_route", "call/${recipient.id}") }, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, - ) - } - - /** Represents a result from a contact or group search. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class ContactSearchResult( - /** The unique identifier. */ - val endpointValue: String, - /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ - val endpointType: String, - /** The human-readable name of the contact or group. */ - val displayName: String, - ) - - /** Result of a message sending operation. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class Result( - /** The unique identifier for the successfully sent message. */ - val messageId: String, - /** A human-readable status message indicating success. */ - val message: String, - ) - - /** Represents a recipient of a message. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class Recipient( - /** Unique identifier for the contact. */ - val id: String, - /** Human-readable name. */ - val name: String, - /** Email address of the contact. */ - val email: String, - ) - - /** Represents a group of recipients. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class ChatGroup( - /** Unique identifier for the group. */ - val id: String, - /** Name of the group. */ - val name: String, - /** List of members belonging to the group. */ - val recipients: List, - ) - } diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt new file mode 100644 index 0000000..c389923 --- /dev/null +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt @@ -0,0 +1,172 @@ +/* + * 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.chatapp.appfunctions + +import android.app.PendingIntent +import android.content.Intent +import android.net.Uri +import androidx.annotation.RequiresApi +import androidx.appfunctions.AppFunctionAppUnknownException +import androidx.appfunctions.AppFunctionElementNotFoundException +import androidx.appfunctions.AppFunctionInvalidArgumentException +import androidx.appfunctions.AppFunctionService +import androidx.appfunctions.AppFunctionServiceEntryPoint +import androidx.appfunctions.AppFunctionStringValueConstraint +import androidx.appfunctions.AppFunction +import com.example.chatapp.data.CallManager +import com.example.chatapp.data.MessageRepository +import com.example.chatapp.data.RecipientsRepository +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * Service entry point for chat-related AppFunctions such as searching contacts, sending messages, and making calls. + */ +@RequiresApi(36) +@AndroidEntryPoint +@AppFunctionServiceEntryPoint( + serviceName = "ChatAppFunctionService", + appFunctionXmlFileName = "chat_app_function_service", +) +abstract class BaseChatAppFunctionService : AppFunctionService() { + @Inject lateinit var messageRepository: MessageRepository + @Inject lateinit var recipientsRepository: RecipientsRepository + @Inject lateinit var callManager: CallManager + + /** + * Search for message recipients or chat groups by name or email. + * Required workflow: Call this before "send" or "makeCall" to obtain a valid endpointValue (unique ID). + * + * @param query Search string for contact name, email, or group name. Can be partial or full names. If blank, returns the most recently contacted entities. + * @param filterType Filter results by entity type. Accepts "INDIVIDUAL" or "GROUP". + * @return List of [ContactSearchResult] objects matching the query. + * @throws AppFunctionInvalidArgumentException If an invalid filterType is provided or if no matching contact or group is found. If thrown, suggest the user clarify the recipient or group name. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchContacts( + query: String, + @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP"]) + filterType: String, + ): List { + val recipients = + when (filterType) { + "INDIVIDUAL" -> { + recipientsRepository.searchRecipients(query, 3).map { + ContactSearchResult( + endpointValue = it.id, + endpointType = "INDIVIDUAL", + displayName = it.name, + ) + } + } + "GROUP" -> { + recipientsRepository.searchGroups(query, 3).map { + ContactSearchResult( + endpointValue = it.id, + endpointType = "GROUP", + displayName = it.name, + ) + } + } + else -> { + recipientsRepository.searchAny(query, maxCount = 3) + .ifEmpty { + throw AppFunctionInvalidArgumentException( + "Only INDIVIDUAL or GROUP are accepted filter arguments.", + ) + } + } + } + + if (recipients.isEmpty()) { + throw AppFunctionInvalidArgumentException( + "$filterType with name $query not found. Ask the user to clarify the name", + ) + } + return recipients + } + + /** + * Send a text message with optional image attachments to an individual contact or group. + * Required workflow: Call "searchContacts" first to obtain a valid endpointValue. + * + * @param endpointValue The unique identifier for the recipient or group obtained from searchContacts. + * @param messageBody The text content of the message to send. Cannot be empty or blank. + * @param imageUris Optional list of image URIs to attach to the message. + * @return A [Result] object containing the messageId and a human-readable success confirmation. + * @throws AppFunctionInvalidArgumentException If messageBody is empty or blank. If thrown, ask the user to provide the message content to send. + * @throws AppFunctionElementNotFoundException If no contact or group matches endpointValue. If thrown, call "searchContacts" to find the correct ID. + * @throws AppFunctionAppUnknownException If sending fails due to a repository error. If thrown, suggest the user retry later. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun send( + endpointValue: String, + messageBody: String, + imageUris: List? = null, + ): Result { + if (messageBody.isBlank()) { + throw AppFunctionInvalidArgumentException("Message body cannot be empty") + } + val displayName = + recipientsRepository.getRecipientById(endpointValue)?.name + ?: recipientsRepository.getGroupById(endpointValue)?.name + ?: throw AppFunctionElementNotFoundException( + "No contact or group found for endpointValue: $endpointValue", + ) + + val sentMessageId = + try { + messageRepository.send( + text = messageBody, + recipientIds = listOf(endpointValue), + imageUris = imageUris?.map { it.toString() }, + ) + } catch (e: Exception) { + throw AppFunctionAppUnknownException("Failed to send message: ${e.message}") + } + + return Result(sentMessageId, "Message sent to: $displayName.") + } + + /** + * Initiate a voice call with an individual contact or group. + * Required workflow: Call "searchContacts" first to obtain a valid endpointValue. + * + * @param endpointValue The unique identifier for the recipient or group obtained from searchContacts. + * @return A [PendingIntent] that launches the call activity in the application UI. + * @throws AppFunctionElementNotFoundException If no recipient exists for endpointValue. If thrown, call "searchContacts" to find the correct ID. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun makeCall( + endpointValue: String, + ): PendingIntent { + val recipient = + recipientsRepository.getRecipientById(endpointValue) + ?: throw AppFunctionElementNotFoundException( + "No recipient exists for endpointValue: $endpointValue", + ) + + callManager.startCall(recipient.id) + + return PendingIntent.getActivity( + this, + 0, + Intent(this, Class.forName("com.example.chatapp.MainActivity")) + .apply { putExtra("nav_route", "call/${recipient.id}") }, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt new file mode 100644 index 0000000..fc24a31 --- /dev/null +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt @@ -0,0 +1,78 @@ +/* + * 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.chatapp.appfunctions + +import androidx.appfunctions.AppFunctionSerializable + +/** + * Identifiers for AppFunctions exposed by ChatApp. + */ +object AppFunctionsIds { + const val SEND_ID = "com.example.chatapp.appfunctions.BaseChatAppFunctionService#send" + const val SEARCH_CONTACTS_ID = "com.example.chatapp.appfunctions.BaseChatAppFunctionService#searchContacts" + const val MAKE_CALL_ID = "com.example.chatapp.appfunctions.BaseChatAppFunctionService#makeCall" +} + +/** + * Represents a result from a contact or group search. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class ContactSearchResult( + /** The unique identifier for the contact or group. */ + val endpointValue: String, + /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ + val endpointType: String, + /** The human-readable display name of the contact or group. */ + val displayName: String, +) + +/** + * Result of a message sending operation. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class Result( + /** The unique identifier for the successfully sent message. */ + val messageId: String, + /** A human-readable status message confirming success. */ + val message: String, +) + +/** + * Represents an individual recipient or contact. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class Recipient( + /** Unique identifier for the contact. */ + val id: String, + /** Human-readable name of the contact. */ + val name: String, + /** Email address of the contact. */ + val email: String, +) + +/** + * Represents a group of chat recipients. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class ChatGroup( + /** Unique identifier for the group. */ + val id: String, + /** Name of the group. */ + val name: String, + /** List of members belonging to the group. */ + val recipients: List, +) \ No newline at end of file diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/CallManager.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/CallManager.kt index de76935..ccafcf8 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/CallManager.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/CallManager.kt @@ -15,7 +15,7 @@ */ package com.example.chatapp.data -import com.example.chatapp.appfunctions.AppFunctions.Recipient +import com.example.chatapp.appfunctions.Recipient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt index fdd153e..02b1024 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt @@ -15,9 +15,9 @@ */ package com.example.chatapp.data -import com.example.chatapp.appfunctions.AppFunctions.ChatGroup -import com.example.chatapp.appfunctions.AppFunctions.ContactSearchResult -import com.example.chatapp.appfunctions.AppFunctions.Recipient +import com.example.chatapp.appfunctions.ChatGroup +import com.example.chatapp.appfunctions.ContactSearchResult +import com.example.chatapp.appfunctions.Recipient import javax.inject.Inject import javax.inject.Singleton diff --git a/ChatApp/wear/build.gradle.kts b/ChatApp/wear/build.gradle.kts index 8c0cd37..8964d2f 100644 --- a/ChatApp/wear/build.gradle.kts +++ b/ChatApp/wear/build.gradle.kts @@ -28,7 +28,7 @@ android { defaultConfig { applicationId = "com.example.chatapp.wear" minSdk = 30 - targetSdk = 35 + targetSdk = 37 versionCode = 1 versionName = "1.0" } @@ -66,7 +66,6 @@ dependencies { // App functions implementation(libs.androidx.appfunctions) - implementation(libs.androidx.appfunctions.service) ksp(libs.androidx.appfunctions.compiler) } diff --git a/ChatApp/wear/src/main/AndroidManifest.xml b/ChatApp/wear/src/main/AndroidManifest.xml index 808f5a9..4329815 100644 --- a/ChatApp/wear/src/main/AndroidManifest.xml +++ b/ChatApp/wear/src/main/AndroidManifest.xml @@ -15,7 +15,9 @@ ~ limitations under the License. --> - + @@ -45,6 +47,21 @@ + + + + + + + diff --git a/ChatApp/wear/src/main/res/xml/app_metadata.xml b/ChatApp/wear/src/main/res/xml/app_metadata.xml index 6382c81..5f58c85 100644 --- a/ChatApp/wear/src/main/res/xml/app_metadata.xml +++ b/ChatApp/wear/src/main/res/xml/app_metadata.xml @@ -15,6 +15,12 @@ ~ limitations under the License. --> - diff --git a/agent/app/build.gradle.kts b/agent/app/build.gradle.kts index 3a63ee6..b2a3b3d 100644 --- a/agent/app/build.gradle.kts +++ b/agent/app/build.gradle.kts @@ -100,7 +100,6 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.appfunctions) - implementation(libs.androidx.appfunctions.service) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) diff --git a/agent/app/src/main/AndroidManifest.xml b/agent/app/src/main/AndroidManifest.xml index 8c4a97e..3a55bd3 100644 --- a/agent/app/src/main/AndroidManifest.xml +++ b/agent/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ --> + + + + + + + + + + + + + + + + + + - throw IllegalStateException( - "Tool execution failed for ${toolCall.functionId}: ${executionResult.exception.message}", - executionResult.exception, - ) + is ExecuteAppFunctionResult.Error -> { + val exception = executionResult.exception + val appException = AppFunctionExceptionFormatter.getAppFunctionException(exception) + if (appException != null) { + results.add( + ToolOutput( + functionId = toolCall.functionId, + callId = toolCall.callId, + result = + AppFunctionExceptionFormatter.format( + appException, + toolCall.functionId, + ), + ), + ) + } else { + throw IllegalStateException( + "Tool execution failed for ${toolCall.functionId}: ${exception.message}", + exception, + ) + } + } } } return ExecuteToolCallsResult.Success(results) diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/AppFunctionExceptionFormatter.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/AppFunctionExceptionFormatter.kt new file mode 100644 index 0000000..c3b381a --- /dev/null +++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/AppFunctionExceptionFormatter.kt @@ -0,0 +1,45 @@ +/* + * 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.AppFunctionException + +/** Formats an [AppFunctionException] into a detailed string representation for LLM tool outputs and debugging. */ +object AppFunctionExceptionFormatter { + /** + * Formats the given [exception] including its class name and message. + */ + fun format(exception: AppFunctionException, functionId: String? = null): String { + val className = exception.javaClass.simpleName + val message = exception.errorMessage ?: exception.message ?: "No error message provided" + val prefix = if (functionId != null) "Tool execution failed for $functionId: " else "" + return "${prefix}Error: $className - $message" + } + + /** + * Extracts an [AppFunctionException] from the given [throwable] or its cause chain, if present. + */ + fun getAppFunctionException(throwable: Throwable?): AppFunctionException? { + var current: Throwable? = throwable + while (current != null) { + if (current is AppFunctionException) { + return current + } + current = current.cause + } + return null + } +} diff --git a/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCase.kt b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCase.kt index 920236d..8bd1ef4 100644 --- a/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCase.kt +++ b/agent/app/src/main/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCase.kt @@ -109,12 +109,7 @@ class ExecuteAppFunctionUseCase } } is ExecuteAppFunctionResponse.Error -> { - val exception = response.error - ExecuteAppFunctionResult.Error( - Exception( - "Execution failed: ${exception.errorMessage} (${exception.javaClass.simpleName})", - ), - ) + ExecuteAppFunctionResult.Error(response.error) } } } catch (e: Exception) { 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 9dc4d62..146d1f6 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 @@ -65,7 +65,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.appfunctions.AppFunctionException import com.example.appfunctions.agent.R +import com.example.appfunctions.agent.domain.appfunction.AppFunctionExceptionFormatter import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionResult import com.example.appfunctions.agent.ui.theme.AppFunctionsAgentTheme import com.example.appfunctions.agent.ui.theme.GoogleSansCodeFontFamily @@ -186,8 +188,19 @@ fun FunctionsFoundContent( when (val result = state.executionResult) { is ExecuteAppFunctionResult.Error -> { SelectionContainer { + val appException = + AppFunctionExceptionFormatter.getAppFunctionException(result.exception) + val errorText = + if (appException != null) { + AppFunctionExceptionFormatter.format( + appException, + state.executedFunction?.id, + ) + } else { + result.exception.message ?: "Unknown error" + } Text( - text = result.exception.message ?: "Unknown error", + text = errorText, style = MaterialTheme.typography.bodyMedium.copy( fontFamily = GoogleSansCodeFontFamily, diff --git a/agent/app/src/main/res/xml/app_metadata.xml b/agent/app/src/main/res/xml/app_metadata.xml new file mode 100644 index 0000000..81cf42b --- /dev/null +++ b/agent/app/src/main/res/xml/app_metadata.xml @@ -0,0 +1,24 @@ + + + diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/domain/AgentOrchestratorTest.kt index 592d28d..4dbf97d 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 @@ -25,6 +25,7 @@ import com.example.appfunctions.agent.data.db.entities.MessageProcessingStatus import com.example.appfunctions.agent.data.db.entities.MessageRole import com.example.appfunctions.agent.data.db.entities.ThreadEntity import com.example.appfunctions.agent.domain.appfunction.ConvertInputToAppFunctionDataUseCase +import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionResult import com.example.appfunctions.agent.domain.appfunction.ExecuteAppFunctionUseCase import com.example.appfunctions.agent.domain.appfunction.GetAppFunctionsUseCase import com.example.appfunctions.agent.domain.chat.ManageThreadsUseCase @@ -48,8 +49,10 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class AgentOrchestratorTest { private val observePendingMessagesUseCase: ObservePendingMessagesUseCase = mockk() private val updateMessageUseCase: UpdateMessageUseCase = mockk(relaxed = true) @@ -253,6 +256,73 @@ class AgentOrchestratorTest { } } + @Test + fun `observeAndProcessMessages feeds AppFunctionException back to LLM as ToolOutput`() = + runTest { + val threadId = "thread_1" + val message = createUserMessage(threadId, "create event") + val thread = createThread(threadId) + val llmProvider = mockk() + + val tool1 = createMockTool("com.example.calendar", "create_event") + every { tool1.parameters } returns emptyList() + every { tool1.components } returns mockk(relaxed = true) + mockAppFunctions(listOf(tool1)) + + setupDefaultMocks(threadId, message, thread, llmProvider = llmProvider) + + coEvery { + convertInputToAppFunctionDataUseCase(any(), any(), any()) + } returns Result.success(mockk()) + + val appException = androidx.appfunctions.AppFunctionPermissionRequiredException("Calendar permission required") + coEvery { + executeAppFunctionUseCase(any(), any(), any()) + } returns ExecuteAppFunctionResult.Error(appException) + + coEvery { + llmProvider.generateResponse(null, any(), any(), any(), any()) + } returns LlmResponse.Success( + "interaction_1", + listOf( + LlmResponsePart.ToolCall( + packageName = "com.example.calendar", + functionId = "create_event", + arguments = emptyMap(), + callId = "call_1", + ) + ) + ) + + val expectedErrorOutput = + "Tool execution failed for create_event: Error: AppFunctionPermissionRequiredException - Calendar permission required" + coEvery { + llmProvider.generateResponse(eq("interaction_1"), any(), any(), any(), any()) + } returns LlmResponse.Success("interaction_2", listOf(LlmResponsePart.Text("Permission needed."))) + + agentOrchestrator.observeAndProcessMessages(threadId) + + coVerify { + llmProvider.generateResponse( + previousInteractionId = eq("interaction_1"), + input = eq( + LlmInput.ToolResponse( + listOf( + ToolOutput( + functionId = "create_event", + callId = "call_1", + result = expectedErrorOutput, + ) + ) + ) + ), + tools = listOf(tool1), + apiKey = "dummy_key", + modelName = any(), + ) + } + } + private fun createUserMessage( threadId: String, textContent: String, diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/domain/appfunction/AppFunctionExceptionFormatterTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/domain/appfunction/AppFunctionExceptionFormatterTest.kt new file mode 100644 index 0000000..b60f919 --- /dev/null +++ b/agent/app/src/test/java/com/example/appfunctions/agent/domain/appfunction/AppFunctionExceptionFormatterTest.kt @@ -0,0 +1,93 @@ +/* + * 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.AppFunctionInvalidArgumentException +import androidx.appfunctions.AppFunctionPermissionRequiredException +import androidx.appfunctions.AppFunctionSystemUnknownException +import androidx.appfunctions.AppFunctionUnknownException +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AppFunctionExceptionFormatterTest { + @Test + fun `format formats AppFunctionPermissionRequiredException correctly`() { + val exception = AppFunctionPermissionRequiredException("Calendar permission required") + val formatted = AppFunctionExceptionFormatter.format(exception) + assertEquals( + "Error: AppFunctionPermissionRequiredException - Calendar permission required", + formatted, + ) + } + + @Test + fun `format formats AppFunctionInvalidArgumentException correctly`() { + val exception = AppFunctionInvalidArgumentException("Invalid date format") + val formatted = AppFunctionExceptionFormatter.format(exception) + assertEquals( + "Error: AppFunctionInvalidArgumentException - Invalid date format", + formatted, + ) + } + + @Test + fun `format formats AppFunctionSystemUnknownException correctly`() { + val exception = AppFunctionSystemUnknownException("Service not found") + val formatted = AppFunctionExceptionFormatter.format(exception) + assertEquals( + "Error: AppFunctionSystemUnknownException - Service not found", + formatted, + ) + } + + @Test + fun `format formats AppFunctionUnknownException correctly`() { + val exception = AppFunctionUnknownException(errorCode = 9999, errorMessage = "Future error") + val formatted = AppFunctionExceptionFormatter.format(exception) + assertEquals( + "Error: AppFunctionUnknownException - Future error", + formatted, + ) + } + + @Test + fun `getAppFunctionException extracts exception from cause chain`() { + val appException = AppFunctionInvalidArgumentException("Invalid arg") + val wrappedException = RuntimeException("Wrapper", RuntimeException("Inner wrapper", appException)) + + val extracted = AppFunctionExceptionFormatter.getAppFunctionException(wrappedException) + assertEquals(appException, extracted) + } + + @Test + fun `format includes functionId prefix when provided`() { + val exception = AppFunctionInvalidArgumentException("Message body cannot be empty") + val formatted = + AppFunctionExceptionFormatter.format( + exception, + "com.example.chatapp.appfunctions.BaseChatAppFunctionService#send", + ) + assertEquals( + "Tool execution failed for com.example.chatapp.appfunctions.BaseChatAppFunctionService#send: Error: AppFunctionInvalidArgumentException - Message body cannot be empty", + formatted, + ) + } +} diff --git a/agent/app/src/test/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCaseTest.kt b/agent/app/src/test/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCaseTest.kt new file mode 100644 index 0000000..49925f2 --- /dev/null +++ b/agent/app/src/test/java/com/example/appfunctions/agent/domain/appfunction/ExecuteAppFunctionUseCaseTest.kt @@ -0,0 +1,88 @@ +/* + * 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.AppFunctionData +import androidx.appfunctions.AppFunctionManager +import androidx.appfunctions.AppFunctionPermissionRequiredException +import androidx.appfunctions.ExecuteAppFunctionResponse +import androidx.appfunctions.metadata.AppFunctionMetadata +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ExecuteAppFunctionUseCaseTest { + private val appFunctionManager = mockk() + private val convertAppFunctionDataToJsonUseCase = mockk() + private val useCase = ExecuteAppFunctionUseCase(appFunctionManager, convertAppFunctionDataToJsonUseCase) + + @Test + fun `invoke returns Error when AppFunctionManager is null`() = runTest { + val useCaseWithNullManager = ExecuteAppFunctionUseCase(null, convertAppFunctionDataToJsonUseCase) + val function = mockk(relaxed = true) + val parameters = mockk() + + val result = useCaseWithNullManager(function, parameters) + + assertTrue(result is ExecuteAppFunctionResult.Error) + val errorResult = result as ExecuteAppFunctionResult.Error + assertTrue(errorResult.exception is IllegalStateException) + assertEquals("AppFunctionManager not available on this device", errorResult.exception.message) + } + + @Test + fun `invoke returns Error with original AppFunctionException when response is Error`() = runTest { + val function = mockk(relaxed = true) + every { function.packageName } returns "com.example.app" + every { function.id } returns "test_function" + val parameters = mockk() + + val appException = AppFunctionPermissionRequiredException("Permission needed") + coEvery { appFunctionManager.executeAppFunction(any()) } returns ExecuteAppFunctionResponse.Error(appException) + + val result = useCase(function, parameters) + + assertTrue(result is ExecuteAppFunctionResult.Error) + val errorResult = result as ExecuteAppFunctionResult.Error + assertEquals(appException, errorResult.exception) + } + + @Test + fun `invoke returns Error when executeAppFunction throws exception`() = runTest { + val function = mockk(relaxed = true) + every { function.packageName } returns "com.example.app" + every { function.id } returns "test_function" + val parameters = mockk() + + val runtimeException = RuntimeException("Unexpected crash") + coEvery { appFunctionManager.executeAppFunction(any()) } throws runtimeException + + val result = useCase(function, parameters) + + assertTrue(result is ExecuteAppFunctionResult.Error) + val errorResult = result as ExecuteAppFunctionResult.Error + assertEquals(runtimeException, errorResult.exception) + } +} diff --git a/agent/gradle/libs.versions.toml b/agent/gradle/libs.versions.toml index f363f9a..a485239 100644 --- a/agent/gradle/libs.versions.toml +++ b/agent/gradle/libs.versions.toml @@ -20,7 +20,7 @@ mockk = "1.14.9" ksp = "2.3.6" hilt = "2.59.2" androidxHiltNavigationCompose = "1.3.0" -appfunctions = "1.0.0-SNAPSHOT" +appfunctions = "1.0.0-alpha10" datastore = "1.2.1" screenshot = "0.0.1-alpha14" coil = "2.7.0" @@ -64,7 +64,6 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" } -androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } diff --git a/agent/settings.gradle.kts b/agent/settings.gradle.kts index 80b8dad..c422771 100644 --- a/agent/settings.gradle.kts +++ b/agent/settings.gradle.kts @@ -32,7 +32,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://androidx.dev/snapshots/builds/15294194/artifacts/repository") } } }