From 72399f8a3add9a9d3d5a3e7e9f69fe3f10d849ca Mon Sep 17 00:00:00 2001 From: corinac123 Date: Thu, 2 Jul 2026 15:18:22 +0100 Subject: [PATCH 01/16] Add endpoint to bob * Add an endpoint for Bob * Repository returns all recipients with same name From b68cce7b3f2ef506245439a2154d718de844fc78 Mon Sep 17 00:00:00 2001 From: Corina Date: Thu, 2 Jul 2026 11:57:14 +0000 Subject: [PATCH 02/16] Removing name as input --- ChatApp/app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ChatApp/app/build.gradle.kts b/ChatApp/app/build.gradle.kts index f6e7155..a24229a 100644 --- a/ChatApp/app/build.gradle.kts +++ b/ChatApp/app/build.gradle.kts @@ -28,8 +28,8 @@ android { applicationId = "com.example.chatapp" minSdk = 24 targetSdk = 37 - versionCode = 1 - versionName = "1.0" + versionCode = 99999 + versionName = "99999" testInstrumentationRunner = "com.example.chatapp.HiltTestRunner" } From 6216262579f44b520cbd2d9c78d3819472e83dec Mon Sep 17 00:00:00 2001 From: Corina Date: Thu, 2 Jul 2026 14:04:48 +0000 Subject: [PATCH 03/16] fix version --- ChatApp/app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ChatApp/app/build.gradle.kts b/ChatApp/app/build.gradle.kts index a24229a..f6e7155 100644 --- a/ChatApp/app/build.gradle.kts +++ b/ChatApp/app/build.gradle.kts @@ -28,8 +28,8 @@ android { applicationId = "com.example.chatapp" minSdk = 24 targetSdk = 37 - versionCode = 99999 - versionName = "99999" + versionCode = 1 + versionName = "1.0" testInstrumentationRunner = "com.example.chatapp.HiltTestRunner" } From 372a5ab12b3bcb784bed2b81669fc119ac79d4b9 Mon Sep 17 00:00:00 2001 From: Corina Date: Thu, 2 Jul 2026 16:09:52 +0000 Subject: [PATCH 04/16] added search messages --- .../AppFunctionInstrumentationTest.kt | 17 +- .../chatapp/appfunctions/AppFunctionsTest.kt | 268 +++++++++++++++++ .../chatapp/data/RecipientsRepository.kt | 170 +++++++++++ .../chatapp/appfunctions/AppFunctions.kt | 280 ++++++++++++++++++ .../chatapp/data/RecipientsRepository.kt | 8 +- 5 files changed, 735 insertions(+), 8 deletions(-) create mode 100644 ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt create mode 100644 ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt create mode 100644 ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt 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 4678127..5eeeb25 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 @@ -70,12 +70,12 @@ class AppFunctionInstrumentationTest { ) .first() .flatMap { it.appFunctions } - .single { it.id == ChatAppFunctionService.FUNCTION_ID_SEND } + .single { it.id == ChatAppFunctionService.FUNCTION_ID_SEND_MESSAGE } val testRecipient = recipientsRepository.getAllRecipients().first() val request = ExecuteAppFunctionRequest( targetPackageName = context.packageName, - ChatAppFunctionService.FUNCTION_ID_SEND, + ChatAppFunctionService.FUNCTION_ID_SEND_MESSAGE, AppFunctionData.Builder( sendMessageFunctionMetadata.parameters, sendMessageFunctionMetadata.components, @@ -109,12 +109,12 @@ class AppFunctionInstrumentationTest { ) .first() .flatMap { it.appFunctions } - .single { it.id == ChatAppFunctionService.FUNCTION_ID_SEND } + .single { it.id == ChatAppFunctionService.FUNCTION_ID_SEND_MESSAGE } val testRecipient = recipientsRepository.getAllRecipients().first() val request = ExecuteAppFunctionRequest( targetPackageName = context.packageName, - ChatAppFunctionService.FUNCTION_ID_SEND, + ChatAppFunctionService.FUNCTION_ID_SEND_MESSAGE, AppFunctionData.Builder( sendMessageFunctionMetadata.parameters, sendMessageFunctionMetadata.components, @@ -151,7 +151,7 @@ class AppFunctionInstrumentationTest { getRecipientsFunctionMetadata.components, ) .setString("query", "Alice") - .setString("filterType", "INDIVIDUAL") + .setString("contactType", "INDIVIDUAL") .build(), ) @@ -165,7 +165,12 @@ class AppFunctionInstrumentationTest { ?.map { it.deserialize(ContactSearchResult::class.java) }, ) .containsExactly( - ContactSearchResult(endpointValue = "1", endpointType = "INDIVIDUAL", displayName = "Alice Smith"), + AppFunctions.ContactSearchResult( + endpointValue = "1", + endpointType = "INDIVIDUAL", + contactDisplayName = "Alice Smith", + endpointDisplayName = "alice@example.com", + ), ) } } 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 new file mode 100644 index 0000000..22b6ced --- /dev/null +++ b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt @@ -0,0 +1,268 @@ +/* + * 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.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 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.chatapp.data.CallManager +import com.example.chatapp.data.DisplayMessage +import com.example.chatapp.data.MessageRepository +import com.example.chatapp.data.RecipientsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test +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()) + + override fun getMessages(recipientId: String): Flow> { + return messages.map { it[recipientId] ?: emptyList() } + } + + override fun saveMessage( + recipientId: String, + message: DisplayMessage, + ) { + messages.value = + messages.value.toMutableMap().apply { + val current = this[recipientId] ?: emptyList() + this[recipientId] = listOf(message) + current + } + } + + override suspend fun send( + text: String, + recipientIds: List, + imageUris: List?, + ): String { + if (shouldFail) { + throw RuntimeException("Failed to send") + } + return "Message ID" + } + + override suspend fun sendToBot(text: String): String { + return "Bot response" + } + } + + private val messageRepository = MockMessageRepository() + + private val recipientsRepository = RecipientsRepository() + + private val callManager = CallManager(recipientsRepository) + + private val appFunctions = AppFunctions(messageRepository, recipientsRepository, callManager) + + @Test(expected = AppFunctionInvalidArgumentException::class) + fun searchContacts_returnsEmptyList() { + runBlocking { + appFunctions.searchContacts(testContext, "nonexistent", "INDIVIDUAL") + } + } + + @Test + fun searchContacts_returnsMatches() { + runBlocking { + val contacts = appFunctions.searchContacts(testContext, "Alice", "INDIVIDUAL") + Assert.assertEquals(1, contacts.size) + Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) + Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) + } + } + + @Test + fun searchContacts_groups_returnsMatches() { + runBlocking { + val contacts = appFunctions.searchContacts(testContext, "Work", "GROUP") + Assert.assertEquals(1, contacts.size) + Assert.assertEquals("Work Friends", contacts[0].contactDisplayName) + Assert.assertEquals("Work Friends", contacts[0].endpointDisplayName) + } + } + + @Test + fun searchContacts_emptyQuery_returnsRecent() { + runBlocking { + val contacts = appFunctions.searchContacts(testContext, "", "INDIVIDUAL") + Assert.assertEquals(3, contacts.size) + } + } + + @Test + fun searchContacts_anyType_returnsMatches() { + runBlocking { + val contacts = appFunctions.searchContacts(testContext, "Alice", "ANY") + Assert.assertEquals(1, contacts.size) + Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) + Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) + } + } + + @Test + fun sendMessage_validMessage_returnsSuccess() { + runTest { + val result = appFunctions.sendMessage(testContext, "1", "Hello") + Assert.assertEquals( + AppFunctions.SendMessageResult( + "Message sent to: Alice Smith.", + ), + result, + ) + } + } + + @Test + fun sendMessage_withImageUris_success() { + runTest { + val result = + appFunctions.sendMessage( + testContext, + "1", + "Hello", + listOf(Uri.parse("content://media/1")), + ) + Assert.assertEquals( + AppFunctions.SendMessageResult( + "Message sent to: Alice Smith.", + ), + result, + ) + } + } + + @Test + fun sendMessage_toGroup_success() { + runTest { + val result = appFunctions.sendMessage(testContext, "g1", "Hello") + Assert.assertEquals( + AppFunctions.SendMessageResult( + "Message sent to: Work Friends.", + ), + result, + ) + } + } + + @Test(expected = AppFunctionInvalidArgumentException::class) + fun sendMessage_emptyContent_fails() { + runTest { + appFunctions.sendMessage(testContext, "1", "") + } + } + + @Test(expected = AppFunctionElementNotFoundException::class) + fun sendMessage_invalidRecipient_fails() { + runTest { + appFunctions.sendMessage(testContext, "nonexistent_id", "Hello") + } + } + + @Test(expected = AppFunctionAppUnknownException::class) + fun sendMessage_repositoryError_returnsError() { + runTest { + messageRepository.shouldFail = true + appFunctions.sendMessage(testContext, "1", "Hello") + } + } + + @Test + fun makeCall_returnsPendingIntent() { + runBlocking { + val pendingIntent = appFunctions.makeCall(testContext, endpointValue = "1") + Assert.assertNotNull(pendingIntent) + } + } + + + @Test(expected = AppFunctionElementNotFoundException::class) + fun makeCall_invalidEndpointValue_fails() { + runBlocking { + appFunctions.makeCall(testContext, endpointValue = "nonexistent_id") + } + } + + @Test + fun searchMessages_allChats_returnsMatches() { + runTest { + messageRepository.saveMessage("1", DisplayMessage("Hello Alice", 1000, isInbound = true)) + messageRepository.saveMessage("2", DisplayMessage("Hello Bob", 2000, isInbound = false)) + messageRepository.saveMessage("1", DisplayMessage("How are you?", 3000, isInbound = false)) + + val results = appFunctions.searchMessages(testContext, "Hello") + + Assert.assertEquals(2, results.size) + + val aliceResult = results.first { it.endpointValue == "1" } + Assert.assertEquals(1, aliceResult.messages.size) + Assert.assertEquals("Hello Alice", aliceResult.messages[0].messageBody) + Assert.assertEquals(1000L, aliceResult.messages[0].timestamp) + Assert.assertFalse(aliceResult.messages[0].isSent) + + val bobResult = results.first { it.endpointValue == "2" } + Assert.assertEquals(1, bobResult.messages.size) + Assert.assertEquals("Hello Bob", bobResult.messages[0].messageBody) + Assert.assertEquals(2000L, bobResult.messages[0].timestamp) + Assert.assertTrue(bobResult.messages[0].isSent) + } + } + + @Test + fun searchMessages_specificChat_returnsMatches() { + runTest { + messageRepository.saveMessage("1", DisplayMessage("Hello Alice", 1000, isInbound = true)) + messageRepository.saveMessage("2", DisplayMessage("Hello Bob", 2000, isInbound = false)) + + val results = appFunctions.searchMessages(testContext, "Hello", endpointValue = "1") + + Assert.assertEquals(1, results.size) + Assert.assertEquals("1", results[0].endpointValue) + Assert.assertEquals("Hello Alice", results[0].messages[0].messageBody) + } + } + + @Test + fun searchMessages_noMatches_returnsEmpty() { + runTest { + messageRepository.saveMessage("1", DisplayMessage("Hello Alice", 1000, isInbound = true)) + + val results = appFunctions.searchMessages(testContext, "Goodbye") + + Assert.assertTrue(results.isEmpty()) + } + } + +} 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 new file mode 100644 index 0000000..40f2758 --- /dev/null +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt @@ -0,0 +1,170 @@ +/* + * 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", + contactDisplayName = it.name, + endpointDisplayName = it.email, + ) + } + val groups = + searchGroups(query, maxCount).map { + ContactSearchResult( + endpointValue = it.id, + endpointType = "GROUP", + contactDisplayName = it.name, + endpointDisplayName = 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/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt new file mode 100644 index 0000000..2374e53 --- /dev/null +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -0,0 +1,280 @@ +/* + * 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 +import kotlinx.coroutines.flow.first + +/** + * 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 contactType Filter results by entity type. Accepts "INDIVIDUAL", "GROUP", or "ANY". + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchContacts( + appFunctionContext: AppFunctionContext, + query: String, + @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP", "ANY"]) + contactType: String, + ): List { + val recipients = + when (contactType) { + "INDIVIDUAL" -> { + recipientsRepository.searchRecipients(query, 3).map { + ContactSearchResult( + endpointValue = it.id, + endpointType = "INDIVIDUAL", + contactDisplayName = it.name, + endpointDisplayName = it.email, + ) + } + } + "GROUP" -> { + recipientsRepository.searchGroups(query, 3).map { + ContactSearchResult( + endpointValue = it.id, + endpointType = "GROUP", + contactDisplayName = it.name, + endpointDisplayName = it.name, + ) + } + } + "ANY" -> { + recipientsRepository.searchAny(query, maxCount = 3) + } + else -> { + throw AppFunctionInvalidArgumentException( + "Invalid contactType: $contactType", + ) + } + } + + if (recipients.isEmpty()) { + throw AppFunctionInvalidArgumentException( + "$contactType 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 sendMessage( + appFunctionContext: AppFunctionContext, + endpointValue: String, + messageBody: String, + imageUris: List? = null, + ): SendMessageResult { + 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 SendMessageResult("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, + ) + } + + /** + * Search for messages containing a query string, optionally filtered by a specific recipient. + * + * @param appFunctionContext The context of this app function call. + * @param query The text to search for within message bodies. + * @param endpointValue Optional unique identifier of the contact or group to restrict the search to. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchMessages( + appFunctionContext: AppFunctionContext, + query: String, + endpointValue: String? = null, + ): List { + val targetIds = + if (endpointValue != null) { + listOf(endpointValue) + } else { + val individuals = recipientsRepository.getAllRecipients().map { it.id } + val groups = recipientsRepository.getAllGroups().map { it.id } + individuals + groups + } + + val results = mutableListOf() + + for (id in targetIds) { + val messages = messageRepository.getMessages(id).first() + val matchingMessages = + messages.filter { it.content.contains(query, ignoreCase = true) } + if (matchingMessages.isNotEmpty()) { + results.add( + MessagesSearchResult( + endpointValue = id, + messages = + matchingMessages.map { + Message( + messageBody = it.content, + timestamp = it.sentAt, + isSent = !it.isInbound, + ) + }, + ) + ) + } + } + + return results + } + + /** 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 contactDisplayName: String, + /** The human-readable name of the endpoint (e.g. email, phone number). */ + val endpointDisplayName: String, + ) + + /** Result of a message sending operation. */ + @AppFunctionSerializable(isDescribedByKDoc = true) + data class SendMessageResult( + /** 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, + ) + + /** Represents a message returned in search results. */ + @AppFunctionSerializable(isDescribedByKDoc = true) + data class Message( + /** The text content of the message. */ + val messageBody: String, + /** The timestamp when the message was sent or received. */ + val timestamp: Long, + /** True if the message was sent by the user, false if it was received. */ + val isSent: Boolean, + ) + + /** Represents the search results for a specific chat endpoint. */ + @AppFunctionSerializable(isDescribedByKDoc = true) + data class MessagesSearchResult( + /** The unique identifier of the contact or group. */ + val endpointValue: String, + /** The list of matching messages found in this chat. */ + val messages: List, + ) + } 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 02b1024..f920070 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 @@ -132,6 +132,8 @@ class RecipientsRepository * @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, @@ -141,7 +143,8 @@ class RecipientsRepository ContactSearchResult( endpointValue = it.id, endpointType = "INDIVIDUAL", - displayName = it.name, + contactDisplayName = it.name, + endpointDisplayName = it.email, ) } val groups = @@ -149,7 +152,8 @@ class RecipientsRepository ContactSearchResult( endpointValue = it.id, endpointType = "GROUP", - displayName = it.name, + contactDisplayName = it.name, + endpointDisplayName = it.name, ) } return mutableListOf().apply { From 1d4663163d6a63b4ea0d56c1f1da0aee8362e44c Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 11:50:19 +0000 Subject: [PATCH 05/16] unnest string --- .../appfunctions/AppFunctionInstrumentationTest.kt | 3 +-- .../example/chatapp/appfunctions/AppFunctionsTest.kt | 12 +++--------- .../com/example/chatapp/appfunctions/AppFunctions.kt | 12 ++++-------- 3 files changed, 8 insertions(+), 19 deletions(-) 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 5eeeb25..877f105 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 @@ -89,8 +89,7 @@ class AppFunctionInstrumentationTest { val successResponse = assertIs(response) assertThat( successResponse.returnValue - .getAppFunctionData(ExecuteAppFunctionResponse.Success.PROPERTY_RETURN_VALUE) - ?.getString("message"), + .getString(ExecuteAppFunctionResponse.Success.PROPERTY_RETURN_VALUE), ) .isEqualTo("Message sent to: Alice Smith.") // Verify that the message was actually saved in the repository 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 22b6ced..23c6c2a 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 @@ -137,9 +137,7 @@ class AppFunctionsTest { runTest { val result = appFunctions.sendMessage(testContext, "1", "Hello") Assert.assertEquals( - AppFunctions.SendMessageResult( - "Message sent to: Alice Smith.", - ), + "Message sent to: Alice Smith.", result, ) } @@ -156,9 +154,7 @@ class AppFunctionsTest { listOf(Uri.parse("content://media/1")), ) Assert.assertEquals( - AppFunctions.SendMessageResult( - "Message sent to: Alice Smith.", - ), + "Message sent to: Alice Smith.", result, ) } @@ -169,9 +165,7 @@ class AppFunctionsTest { runTest { val result = appFunctions.sendMessage(testContext, "g1", "Hello") Assert.assertEquals( - AppFunctions.SendMessageResult( - "Message sent to: Work Friends.", - ), + "Message sent to: Work Friends.", result, ) } 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 index 2374e53..3a7a450 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -103,6 +103,7 @@ class AppFunctions * @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. + * @return A human-readable message indicating the error or confirmation. */ @AppFunction(isDescribedByKDoc = true) suspend fun sendMessage( @@ -110,7 +111,7 @@ class AppFunctions endpointValue: String, messageBody: String, imageUris: List? = null, - ): SendMessageResult { + ): String { if (messageBody.isBlank()) { throw AppFunctionInvalidArgumentException("Message body cannot be empty") } @@ -134,7 +135,7 @@ class AppFunctions // 3. RETURN: Provide a confirmation string // The bot may use this string or the fact that it didn't throw to confirm success. - return SendMessageResult("Message sent to: $displayName.") + return "Message sent to: $displayName." } /** @@ -229,12 +230,7 @@ class AppFunctions val endpointDisplayName: String, ) - /** Result of a message sending operation. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class SendMessageResult( - /** A human-readable status message indicating success. */ - val message: String, - ) + /** Represents a recipient of a message. */ @AppFunctionSerializable(isDescribedByKDoc = true) From 3980cc79e9ffb555c1b13a5447ccef9e8b7ba08d Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 11:57:53 +0000 Subject: [PATCH 06/16] add sender name --- .../chatapp/appfunctions/AppFunctionsTest.kt | 4 ++-- .../example/chatapp/appfunctions/AppFunctions.kt | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) 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 23c6c2a..8bacfe7 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 @@ -224,13 +224,13 @@ class AppFunctionsTest { Assert.assertEquals(1, aliceResult.messages.size) Assert.assertEquals("Hello Alice", aliceResult.messages[0].messageBody) Assert.assertEquals(1000L, aliceResult.messages[0].timestamp) - Assert.assertFalse(aliceResult.messages[0].isSent) + Assert.assertEquals("Alice Smith", aliceResult.messages[0].senderDisplayName) val bobResult = results.first { it.endpointValue == "2" } Assert.assertEquals(1, bobResult.messages.size) Assert.assertEquals("Hello Bob", bobResult.messages[0].messageBody) Assert.assertEquals(2000L, bobResult.messages[0].timestamp) - Assert.assertTrue(bobResult.messages[0].isSent) + Assert.assertEquals("Me", bobResult.messages[0].senderDisplayName) } } 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 index 3a7a450..d4573df 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -203,10 +203,18 @@ class AppFunctions endpointValue = id, messages = matchingMessages.map { + val senderDisplayName = it.senderName + ?: if (it.isInbound) { + recipientsRepository.getRecipientById(id)?.name + ?: recipientsRepository.getGroupById(id)?.name + ?: "Other" + } else { + "Me" + } Message( messageBody = it.content, timestamp = it.sentAt, - isSent = !it.isInbound, + senderDisplayName = senderDisplayName, ) }, ) @@ -261,8 +269,8 @@ class AppFunctions val messageBody: String, /** The timestamp when the message was sent or received. */ val timestamp: Long, - /** True if the message was sent by the user, false if it was received. */ - val isSent: Boolean, + /** The human-readable name of the sender. */ + val senderDisplayName: String, ) /** Represents the search results for a specific chat endpoint. */ From 4b2468696bf46edc7664bd3df350731e30dbea84 Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 11:59:43 +0000 Subject: [PATCH 07/16] javadoc --- .../kotlin/com/example/chatapp/appfunctions/AppFunctions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d4573df..8ca6f8d 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -278,7 +278,7 @@ class AppFunctions data class MessagesSearchResult( /** The unique identifier of the contact or group. */ val endpointValue: String, - /** The list of matching messages found in this chat. */ + /** The list of relevant messages found in this chat. */ val messages: List, ) } From 3e8cc98c4c49baacb9589db9efa6d521ade9fcae Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 12:23:09 +0000 Subject: [PATCH 08/16] nest contact --- .../AppFunctionInstrumentationTest.kt | 10 ++-- .../chatapp/appfunctions/AppFunctionsTest.kt | 30 +++++++++-- .../chatapp/data/RecipientsRepository.kt | 54 +++++++++++-------- .../chatapp/appfunctions/AppFunctions.kt | 36 +++++++------ .../chatapp/data/RecipientsRepository.kt | 54 +++++++++++-------- 5 files changed, 118 insertions(+), 66 deletions(-) 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 877f105..97ed900 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 @@ -165,10 +165,14 @@ class AppFunctionInstrumentationTest { ) .containsExactly( AppFunctions.ContactSearchResult( - endpointValue = "1", - endpointType = "INDIVIDUAL", contactDisplayName = "Alice Smith", - endpointDisplayName = "alice@example.com", + endpointType = "INDIVIDUAL", + endpoints = listOf( + AppFunctions.Endpoint( + endpointValue = "1", + endpointDisplayName = "alice@example.com", + ) + ) ), ) } 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 8bacfe7..4b09c88 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 @@ -100,7 +100,10 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Alice", "INDIVIDUAL") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) - Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) + Assert.assertEquals("INDIVIDUAL", contacts[0].endpointType) + Assert.assertEquals(1, contacts[0].endpoints.size) + Assert.assertEquals("1", contacts[0].endpoints[0].endpointValue) + Assert.assertEquals("alice@example.com", contacts[0].endpoints[0].endpointDisplayName) } } @@ -110,7 +113,10 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Work", "GROUP") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Work Friends", contacts[0].contactDisplayName) - Assert.assertEquals("Work Friends", contacts[0].endpointDisplayName) + Assert.assertEquals("GROUP", contacts[0].endpointType) + Assert.assertEquals(1, contacts[0].endpoints.size) + Assert.assertEquals("g1", contacts[0].endpoints[0].endpointValue) + Assert.assertEquals("Work Friends", contacts[0].endpoints[0].endpointDisplayName) } } @@ -128,7 +134,25 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Alice", "ANY") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) - Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) + Assert.assertEquals("INDIVIDUAL", contacts[0].endpointType) + Assert.assertEquals(1, contacts[0].endpoints.size) + Assert.assertEquals("1", contacts[0].endpoints[0].endpointValue) + Assert.assertEquals("alice@example.com", contacts[0].endpoints[0].endpointDisplayName) + } + } + + @Test + fun searchContacts_duplicateNamesMerged() { + runBlocking { + val contacts = appFunctions.searchContacts(testContext, "Bob", "INDIVIDUAL") + Assert.assertEquals(1, contacts.size) + Assert.assertEquals("Bob Johnson", contacts[0].contactDisplayName) + Assert.assertEquals("INDIVIDUAL", contacts[0].endpointType) + Assert.assertEquals(2, contacts[0].endpoints.size) + Assert.assertEquals("2", contacts[0].endpoints[0].endpointValue) + Assert.assertEquals("bob@example.com", contacts[0].endpoints[0].endpointDisplayName) + Assert.assertEquals("7", contacts[0].endpoints[1].endpointValue) + Assert.assertEquals("bob2@example.com", contacts[0].endpoints[1].endpointDisplayName) } } 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 index 40f2758..ba4f3c9 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt @@ -18,6 +18,7 @@ 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.AppFunctions.Endpoint import javax.inject.Inject import javax.inject.Singleton @@ -87,18 +88,31 @@ class RecipientsRepository fun searchRecipients( query: String?, maxCount: Int, - ): List { - if (query.isNullOrBlank()) { - // TODO:Return most recently contacted. - return recipients.take(maxCount) + ): List { + val matched = if (query.isNullOrBlank()) { + recipients + } else { + recipients.filter { + it.name.contains(query, ignoreCase = true) || + it.email.contains( + query, + ignoreCase = true, + ) + } } - return recipients.filter { - it.name.contains(query, ignoreCase = true) || - it.email.contains( - query, - ignoreCase = true, - ) + val grouped = matched.groupBy { it.name }.map { (name, list) -> + ContactSearchResult( + contactDisplayName = name, + endpointType = "INDIVIDUAL", + endpoints = list.map { Endpoint(it.id, it.email) } + ) + } + + return if (query.isNullOrBlank()) { + grouped.take(maxCount) + } else { + grouped } } @@ -138,22 +152,18 @@ class RecipientsRepository query: String?, maxCount: Int, ): List { - val individuals = - searchRecipients(query, maxCount).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - contactDisplayName = it.name, - endpointDisplayName = it.email, - ) - } + val individuals = searchRecipients(query, maxCount) val groups = searchGroups(query, maxCount).map { ContactSearchResult( - endpointValue = it.id, - endpointType = "GROUP", contactDisplayName = it.name, - endpointDisplayName = it.name, + endpointType = "GROUP", + endpoints = listOf( + Endpoint( + endpointValue = it.id, + endpointDisplayName = it.name, + ) + ) ) } return mutableListOf().apply { 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 index 8ca6f8d..24e01a1 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -59,22 +59,19 @@ class AppFunctions val recipients = when (contactType) { "INDIVIDUAL" -> { - recipientsRepository.searchRecipients(query, 3).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - contactDisplayName = it.name, - endpointDisplayName = it.email, - ) - } + recipientsRepository.searchRecipients(query, 3) } "GROUP" -> { recipientsRepository.searchGroups(query, 3).map { ContactSearchResult( - endpointValue = it.id, - endpointType = "GROUP", contactDisplayName = it.name, - endpointDisplayName = it.name, + endpointType = "GROUP", + endpoints = listOf( + Endpoint( + endpointValue = it.id, + endpointDisplayName = it.name, + ) + ) ) } } @@ -228,13 +225,20 @@ class AppFunctions /** 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 contactDisplayName: String, - /** The human-readable name of the endpoint (e.g. email, phone number). */ + /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ + val endpointType: String, + /** The list of endpoints associated with this contact or group. */ + val endpoints: List, + ) + + /** Represents an endpoint of a contact or group. */ + @AppFunctionSerializable(isDescribedByKDoc = true) + data class Endpoint( + /** The unique identifier of the endpoint. */ + val endpointValue: String, + /** The human-readable label/display name of the endpoint. */ val endpointDisplayName: String, ) 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 f920070..eb33442 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 @@ -18,6 +18,7 @@ package com.example.chatapp.data import com.example.chatapp.appfunctions.ChatGroup import com.example.chatapp.appfunctions.ContactSearchResult import com.example.chatapp.appfunctions.Recipient +import com.example.chatapp.appfunctions.Endpoint import javax.inject.Inject import javax.inject.Singleton @@ -87,18 +88,31 @@ class RecipientsRepository fun searchRecipients( query: String?, maxCount: Int, - ): List { - if (query.isNullOrBlank()) { - // TODO:Return most recently contacted. - return recipients.take(maxCount) + ): List { + val matched = if (query.isNullOrBlank()) { + recipients + } else { + recipients.filter { + it.name.contains(query, ignoreCase = true) || + it.email.contains( + query, + ignoreCase = true, + ) + } } - return recipients.filter { - it.name.contains(query, ignoreCase = true) || - it.email.contains( - query, - ignoreCase = true, - ) + val grouped = matched.groupBy { it.name }.map { (name, list) -> + ContactSearchResult( + contactDisplayName = name, + endpointType = "INDIVIDUAL", + endpoints = list.map { Endpoint(it.id, it.email) } + ) + } + + return if (query.isNullOrBlank()) { + grouped.take(maxCount) + } else { + grouped } } @@ -138,22 +152,18 @@ class RecipientsRepository query: String?, maxCount: Int, ): List { - val individuals = - searchRecipients(query, maxCount).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - contactDisplayName = it.name, - endpointDisplayName = it.email, - ) - } + val individuals = searchRecipients(query, maxCount) val groups = searchGroups(query, maxCount).map { ContactSearchResult( - endpointValue = it.id, - endpointType = "GROUP", contactDisplayName = it.name, - endpointDisplayName = it.name, + endpointType = "GROUP", + endpoints = listOf( + Endpoint( + endpointValue = it.id, + endpointDisplayName = it.name, + ) + ) ) } return mutableListOf().apply { From 5f8f3097c40bb197729805ef6124185a88a20030 Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 12:26:22 +0000 Subject: [PATCH 09/16] throw if empty query --- .../com/example/chatapp/appfunctions/AppFunctionsTest.kt | 7 +++---- .../com/example/chatapp/appfunctions/AppFunctions.kt | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) 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 4b09c88..6dab78b 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 @@ -120,11 +120,10 @@ class AppFunctionsTest { } } - @Test - fun searchContacts_emptyQuery_returnsRecent() { + @Test(expected = AppFunctionInvalidArgumentException::class) + fun searchContacts_emptyQuery_fails() { runBlocking { - val contacts = appFunctions.searchContacts(testContext, "", "INDIVIDUAL") - Assert.assertEquals(3, contacts.size) + appFunctions.searchContacts(testContext, "", "INDIVIDUAL") } } 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 index 24e01a1..a4d0ab6 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -56,6 +56,9 @@ class AppFunctions @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP", "ANY"]) contactType: String, ): List { + if (query.isBlank()) { + throw AppFunctionInvalidArgumentException("Query cannot be empty") + } val recipients = when (contactType) { "INDIVIDUAL" -> { From d25048676369afe16c9cef92121a3c8b702d9b2a Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 12:32:49 +0000 Subject: [PATCH 10/16] rename --- .../appfunctions/AppFunctionInstrumentationTest.kt | 2 +- .../com/example/chatapp/appfunctions/AppFunctionsTest.kt | 8 ++++---- .../com/example/chatapp/data/RecipientsRepository.kt | 4 ++-- .../com/example/chatapp/appfunctions/AppFunctions.kt | 8 ++++---- .../com/example/chatapp/data/RecipientsRepository.kt | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) 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 97ed900..5b4d4da 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 @@ -166,7 +166,7 @@ class AppFunctionInstrumentationTest { .containsExactly( AppFunctions.ContactSearchResult( contactDisplayName = "Alice Smith", - endpointType = "INDIVIDUAL", + contactType = "INDIVIDUAL", endpoints = listOf( AppFunctions.Endpoint( endpointValue = "1", 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 6dab78b..d0b52dd 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 @@ -100,7 +100,7 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Alice", "INDIVIDUAL") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[0].endpointType) + Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) Assert.assertEquals(1, contacts[0].endpoints.size) Assert.assertEquals("1", contacts[0].endpoints[0].endpointValue) Assert.assertEquals("alice@example.com", contacts[0].endpoints[0].endpointDisplayName) @@ -113,7 +113,7 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Work", "GROUP") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Work Friends", contacts[0].contactDisplayName) - Assert.assertEquals("GROUP", contacts[0].endpointType) + Assert.assertEquals("GROUP", contacts[0].contactType) Assert.assertEquals(1, contacts[0].endpoints.size) Assert.assertEquals("g1", contacts[0].endpoints[0].endpointValue) Assert.assertEquals("Work Friends", contacts[0].endpoints[0].endpointDisplayName) @@ -133,7 +133,7 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Alice", "ANY") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[0].endpointType) + Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) Assert.assertEquals(1, contacts[0].endpoints.size) Assert.assertEquals("1", contacts[0].endpoints[0].endpointValue) Assert.assertEquals("alice@example.com", contacts[0].endpoints[0].endpointDisplayName) @@ -146,7 +146,7 @@ class AppFunctionsTest { val contacts = appFunctions.searchContacts(testContext, "Bob", "INDIVIDUAL") Assert.assertEquals(1, contacts.size) Assert.assertEquals("Bob Johnson", contacts[0].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[0].endpointType) + Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) Assert.assertEquals(2, contacts[0].endpoints.size) Assert.assertEquals("2", contacts[0].endpoints[0].endpointValue) Assert.assertEquals("bob@example.com", contacts[0].endpoints[0].endpointDisplayName) 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 index ba4f3c9..cd4a25c 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt @@ -104,7 +104,7 @@ class RecipientsRepository val grouped = matched.groupBy { it.name }.map { (name, list) -> ContactSearchResult( contactDisplayName = name, - endpointType = "INDIVIDUAL", + contactType = "INDIVIDUAL", endpoints = list.map { Endpoint(it.id, it.email) } ) } @@ -157,7 +157,7 @@ class RecipientsRepository searchGroups(query, maxCount).map { ContactSearchResult( contactDisplayName = it.name, - endpointType = "GROUP", + contactType = "GROUP", endpoints = listOf( Endpoint( endpointValue = it.id, 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 index a4d0ab6..39bf38c 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -45,9 +45,9 @@ class AppFunctions * 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 query Search string for the contact or group name. Can be partial or full names. Throws [AppFunctionInvalidArgumentException] if empty. * @param contactType Filter results by entity type. Accepts "INDIVIDUAL", "GROUP", or "ANY". + * @throws AppFunctionInvalidArgumentException if the query is blank or no matching contacts/groups are found. */ @AppFunction(isDescribedByKDoc = true) suspend fun searchContacts( @@ -68,7 +68,7 @@ class AppFunctions recipientsRepository.searchGroups(query, 3).map { ContactSearchResult( contactDisplayName = it.name, - endpointType = "GROUP", + contactType = "GROUP", endpoints = listOf( Endpoint( endpointValue = it.id, @@ -231,7 +231,7 @@ class AppFunctions /** The human-readable name of the contact or group. */ val contactDisplayName: String, /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ - val endpointType: String, + val contactType: String, /** The list of endpoints associated with this contact or group. */ val endpoints: List, ) 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 eb33442..4c69b3d 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 @@ -104,7 +104,7 @@ class RecipientsRepository val grouped = matched.groupBy { it.name }.map { (name, list) -> ContactSearchResult( contactDisplayName = name, - endpointType = "INDIVIDUAL", + contactType = "INDIVIDUAL", endpoints = list.map { Endpoint(it.id, it.email) } ) } @@ -157,7 +157,7 @@ class RecipientsRepository searchGroups(query, maxCount).map { ContactSearchResult( contactDisplayName = it.name, - endpointType = "GROUP", + contactType = "GROUP", endpoints = listOf( Endpoint( endpointValue = it.id, From 4940f760fd0a35b3ed519af11d60daa86e6e4b79 Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 7 Jul 2026 13:32:04 +0000 Subject: [PATCH 11/16] revert nest --- .../AppFunctionInstrumentationTest.kt | 8 ++--- .../chatapp/appfunctions/AppFunctionsTest.kt | 32 +++++++++---------- .../chatapp/data/RecipientsRepository.kt | 20 +++++------- .../chatapp/appfunctions/AppFunctions.kt | 15 ++------- .../chatapp/data/RecipientsRepository.kt | 19 +++++------ 5 files changed, 36 insertions(+), 58 deletions(-) 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 5b4d4da..b7a3c1a 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 @@ -167,12 +167,8 @@ class AppFunctionInstrumentationTest { AppFunctions.ContactSearchResult( contactDisplayName = "Alice Smith", contactType = "INDIVIDUAL", - endpoints = listOf( - AppFunctions.Endpoint( - endpointValue = "1", - endpointDisplayName = "alice@example.com", - ) - ) + endpointValue = "1", + endpointDisplayName = "alice@example.com", ), ) } 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 d0b52dd..7ad8ea5 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 @@ -101,9 +101,8 @@ class AppFunctionsTest { Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) - Assert.assertEquals(1, contacts[0].endpoints.size) - Assert.assertEquals("1", contacts[0].endpoints[0].endpointValue) - Assert.assertEquals("alice@example.com", contacts[0].endpoints[0].endpointDisplayName) + Assert.assertEquals("1", contacts[0].endpointValue) + Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) } } @@ -114,9 +113,8 @@ class AppFunctionsTest { Assert.assertEquals(1, contacts.size) Assert.assertEquals("Work Friends", contacts[0].contactDisplayName) Assert.assertEquals("GROUP", contacts[0].contactType) - Assert.assertEquals(1, contacts[0].endpoints.size) - Assert.assertEquals("g1", contacts[0].endpoints[0].endpointValue) - Assert.assertEquals("Work Friends", contacts[0].endpoints[0].endpointDisplayName) + Assert.assertEquals("g1", contacts[0].endpointValue) + Assert.assertEquals("Work Friends", contacts[0].endpointDisplayName) } } @@ -134,24 +132,26 @@ class AppFunctionsTest { Assert.assertEquals(1, contacts.size) Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) - Assert.assertEquals(1, contacts[0].endpoints.size) - Assert.assertEquals("1", contacts[0].endpoints[0].endpointValue) - Assert.assertEquals("alice@example.com", contacts[0].endpoints[0].endpointDisplayName) + Assert.assertEquals("1", contacts[0].endpointValue) + Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) } } @Test - fun searchContacts_duplicateNamesMerged() { + fun searchContacts_duplicateNamesReturnedAsSeparate() { runBlocking { val contacts = appFunctions.searchContacts(testContext, "Bob", "INDIVIDUAL") - Assert.assertEquals(1, contacts.size) + Assert.assertEquals(2, contacts.size) + Assert.assertEquals("Bob Johnson", contacts[0].contactDisplayName) Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) - Assert.assertEquals(2, contacts[0].endpoints.size) - Assert.assertEquals("2", contacts[0].endpoints[0].endpointValue) - Assert.assertEquals("bob@example.com", contacts[0].endpoints[0].endpointDisplayName) - Assert.assertEquals("7", contacts[0].endpoints[1].endpointValue) - Assert.assertEquals("bob2@example.com", contacts[0].endpoints[1].endpointDisplayName) + Assert.assertEquals("2", contacts[0].endpointValue) + Assert.assertEquals("bob@example.com", contacts[0].endpointDisplayName) + + Assert.assertEquals("Bob Johnson", contacts[1].contactDisplayName) + Assert.assertEquals("INDIVIDUAL", contacts[1].contactType) + Assert.assertEquals("7", contacts[1].endpointValue) + Assert.assertEquals("bob2@example.com", contacts[1].endpointDisplayName) } } 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 index cd4a25c..85b33da 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt @@ -18,7 +18,6 @@ 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.AppFunctions.Endpoint import javax.inject.Inject import javax.inject.Singleton @@ -101,18 +100,19 @@ class RecipientsRepository } } - val grouped = matched.groupBy { it.name }.map { (name, list) -> + val mapped = matched.map { ContactSearchResult( - contactDisplayName = name, + contactDisplayName = it.name, contactType = "INDIVIDUAL", - endpoints = list.map { Endpoint(it.id, it.email) } + endpointValue = it.id, + endpointDisplayName = it.email, ) } return if (query.isNullOrBlank()) { - grouped.take(maxCount) + mapped.take(maxCount) } else { - grouped + mapped } } @@ -158,12 +158,8 @@ class RecipientsRepository ContactSearchResult( contactDisplayName = it.name, contactType = "GROUP", - endpoints = listOf( - Endpoint( - endpointValue = it.id, - endpointDisplayName = it.name, - ) - ) + endpointValue = it.id, + endpointDisplayName = it.name, ) } return mutableListOf().apply { 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 index 39bf38c..bcc0da1 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -69,12 +69,8 @@ class AppFunctions ContactSearchResult( contactDisplayName = it.name, contactType = "GROUP", - endpoints = listOf( - Endpoint( - endpointValue = it.id, - endpointDisplayName = it.name, - ) - ) + endpointValue = it.id, + endpointDisplayName = it.name, ) } } @@ -232,13 +228,6 @@ class AppFunctions val contactDisplayName: String, /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ val contactType: String, - /** The list of endpoints associated with this contact or group. */ - val endpoints: List, - ) - - /** Represents an endpoint of a contact or group. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class Endpoint( /** The unique identifier of the endpoint. */ val endpointValue: String, /** The human-readable label/display name of the endpoint. */ 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 4c69b3d..06a617e 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 @@ -101,18 +101,19 @@ class RecipientsRepository } } - val grouped = matched.groupBy { it.name }.map { (name, list) -> + val mapped = matched.map { ContactSearchResult( - contactDisplayName = name, + contactDisplayName = it.name, contactType = "INDIVIDUAL", - endpoints = list.map { Endpoint(it.id, it.email) } + endpointValue = it.id, + endpointDisplayName = it.email, ) } return if (query.isNullOrBlank()) { - grouped.take(maxCount) + mapped.take(maxCount) } else { - grouped + mapped } } @@ -158,12 +159,8 @@ class RecipientsRepository ContactSearchResult( contactDisplayName = it.name, contactType = "GROUP", - endpoints = listOf( - Endpoint( - endpointValue = it.id, - endpointDisplayName = it.name, - ) - ) + endpointValue = it.id, + endpointDisplayName = it.name, ) } return mutableListOf().apply { From 6207beee23c5879274b9519e57005b0f5b782bbe Mon Sep 17 00:00:00 2001 From: Corina Date: Thu, 9 Jul 2026 12:28:11 +0000 Subject: [PATCH 12/16] empty check --- .../com/example/chatapp/appfunctions/AppFunctionsTest.kt | 8 ++++++++ .../com/example/chatapp/appfunctions/AppFunctions.kt | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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 7ad8ea5..1d8a498 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 @@ -282,4 +282,12 @@ class AppFunctionsTest { } } + @Test(expected = AppFunctionInvalidArgumentException::class) + fun searchMessages_emptyQuery_fails() { + runTest { + appFunctions.searchMessages(testContext, "") + } + } + } + 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 index bcc0da1..05ac2b9 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt @@ -169,8 +169,9 @@ class AppFunctions * Search for messages containing a query string, optionally filtered by a specific recipient. * * @param appFunctionContext The context of this app function call. - * @param query The text to search for within message bodies. + * @param query The text to search for within message bodies. Throws [AppFunctionInvalidArgumentException] if empty. * @param endpointValue Optional unique identifier of the contact or group to restrict the search to. + * @throws AppFunctionInvalidArgumentException if the query is blank. */ @AppFunction(isDescribedByKDoc = true) suspend fun searchMessages( @@ -178,6 +179,9 @@ class AppFunctions query: String, endpointValue: String? = null, ): List { + if (query.isBlank()) { + throw AppFunctionInvalidArgumentException("Query cannot be empty") + } val targetIds = if (endpointValue != null) { listOf(endpointValue) From d843bc99ef9bea12409e3334d0d62a9509253255 Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 21 Jul 2026 15:39:07 +0000 Subject: [PATCH 13/16] updates post merge --- .../chatapp/data/RecipientsRepository.kt | 6 +- .../chatapp/appfunctions/AppFunctions.kt | 284 ------------------ .../BaseChatAppFunctionService.kt | 73 ++++- .../com/example/chatapp/appfunctions/Util.kt | 36 ++- .../chatapp/data/RecipientsRepository.kt | 1 - 5 files changed, 98 insertions(+), 302 deletions(-) delete mode 100644 ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt 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 index 85b33da..702719b 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ b/ChatApp/app/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/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 05ac2b9..0000000 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/AppFunctions.kt +++ /dev/null @@ -1,284 +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 -import kotlinx.coroutines.flow.first - -/** - * 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. Throws [AppFunctionInvalidArgumentException] if empty. - * @param contactType Filter results by entity type. Accepts "INDIVIDUAL", "GROUP", or "ANY". - * @throws AppFunctionInvalidArgumentException if the query is blank or no matching contacts/groups are found. - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun searchContacts( - appFunctionContext: AppFunctionContext, - query: String, - @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP", "ANY"]) - contactType: String, - ): List { - if (query.isBlank()) { - throw AppFunctionInvalidArgumentException("Query cannot be empty") - } - val recipients = - when (contactType) { - "INDIVIDUAL" -> { - recipientsRepository.searchRecipients(query, 3) - } - "GROUP" -> { - recipientsRepository.searchGroups(query, 3).map { - ContactSearchResult( - contactDisplayName = it.name, - contactType = "GROUP", - endpointValue = it.id, - endpointDisplayName = it.name, - ) - } - } - "ANY" -> { - recipientsRepository.searchAny(query, maxCount = 3) - } - else -> { - throw AppFunctionInvalidArgumentException( - "Invalid contactType: $contactType", - ) - } - } - - if (recipients.isEmpty()) { - throw AppFunctionInvalidArgumentException( - "$contactType 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. - * @return A human-readable message indicating the error or confirmation. - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun sendMessage( - appFunctionContext: AppFunctionContext, - endpointValue: String, - messageBody: String, - imageUris: List? = null, - ): String { - 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 "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, - ) - } - - /** - * Search for messages containing a query string, optionally filtered by a specific recipient. - * - * @param appFunctionContext The context of this app function call. - * @param query The text to search for within message bodies. Throws [AppFunctionInvalidArgumentException] if empty. - * @param endpointValue Optional unique identifier of the contact or group to restrict the search to. - * @throws AppFunctionInvalidArgumentException if the query is blank. - */ - @AppFunction(isDescribedByKDoc = true) - suspend fun searchMessages( - appFunctionContext: AppFunctionContext, - query: String, - endpointValue: String? = null, - ): List { - if (query.isBlank()) { - throw AppFunctionInvalidArgumentException("Query cannot be empty") - } - val targetIds = - if (endpointValue != null) { - listOf(endpointValue) - } else { - val individuals = recipientsRepository.getAllRecipients().map { it.id } - val groups = recipientsRepository.getAllGroups().map { it.id } - individuals + groups - } - - val results = mutableListOf() - - for (id in targetIds) { - val messages = messageRepository.getMessages(id).first() - val matchingMessages = - messages.filter { it.content.contains(query, ignoreCase = true) } - if (matchingMessages.isNotEmpty()) { - results.add( - MessagesSearchResult( - endpointValue = id, - messages = - matchingMessages.map { - val senderDisplayName = it.senderName - ?: if (it.isInbound) { - recipientsRepository.getRecipientById(id)?.name - ?: recipientsRepository.getGroupById(id)?.name - ?: "Other" - } else { - "Me" - } - Message( - messageBody = it.content, - timestamp = it.sentAt, - senderDisplayName = senderDisplayName, - ) - }, - ) - ) - } - } - - return results - } - - /** Represents a result from a contact or group search. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class ContactSearchResult( - /** The human-readable name of the contact or group. */ - val contactDisplayName: String, - /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ - val contactType: String, - /** The unique identifier of the endpoint. */ - val endpointValue: String, - /** The human-readable label/display name of the endpoint. */ - val endpointDisplayName: 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, - ) - - /** Represents a message returned in search results. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class Message( - /** The text content of the message. */ - val messageBody: String, - /** The timestamp when the message was sent or received. */ - val timestamp: Long, - /** The human-readable name of the sender. */ - val senderDisplayName: String, - ) - - /** Represents the search results for a specific chat endpoint. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class MessagesSearchResult( - /** The unique identifier of the contact or group. */ - val endpointValue: String, - /** The list of relevant messages found in this chat. */ - val messages: 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 index cc13cfd..4694726 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt @@ -32,6 +32,7 @@ import com.example.chatapp.data.RecipientsRepository import com.example.chatapp.data.WallpaperRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first import javax.inject.Inject /** @@ -70,20 +71,15 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { val recipients = when (filterType) { "INDIVIDUAL" -> { - recipientsRepository.searchRecipients(query, 3).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - displayName = it.name, - ) - } + recipientsRepository.searchRecipients(query, 3) } "GROUP" -> { recipientsRepository.searchGroups(query, 3).map { ContactSearchResult( + contactDisplayName = it.name, + contactType = "GROUP", endpointValue = it.id, - endpointType = "GROUP", - displayName = it.name, + endpointDisplayName = it.name, ) } } @@ -202,4 +198,63 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { wallpaperRepository.setWallpaper(resolvedId, stream) } } + + /** + * Search for messages containing a query string, optionally filtered by a specific recipient. + * + * @param query The text to search for within message bodies. Cannot be empty. + * @param endpointValue Optional unique identifier of the contact or group to restrict the search to. + * @return List of [MessagesSearchResult] matching the query. + * @throws AppFunctionInvalidArgumentException If the query is blank. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchMessages( + query: String, + endpointValue: String? = null, + ): List { + if (query.isBlank()) { + throw AppFunctionInvalidArgumentException("Query cannot be empty") + } + val targetIds = + if (endpointValue != null) { + listOf(endpointValue) + } else { + val individuals = recipientsRepository.getAllRecipients().map { it.id } + val groups = recipientsRepository.getAllGroups().map { it.id } + individuals + groups + } + + val results = mutableListOf() + + for (id in targetIds) { + val messages = messageRepository.getMessages(id).first() + val matchingMessages = + messages.filter { it.content.contains(query, ignoreCase = true) } + if (matchingMessages.isNotEmpty()) { + results.add( + MessagesSearchResult( + endpointValue = id, + messages = + matchingMessages.map { + val senderDisplayName = it.senderName + ?: if (it.isInbound) { + recipientsRepository.getRecipientById(id)?.name + ?: recipientsRepository.getGroupById(id)?.name + ?: "Other" + } else { + "Me" + } + Message( + messageBody = it.content, + timestamp = it.sentAt, + senderDisplayName = senderDisplayName, + ) + }, + ) + ) + } + } + + return results + } } 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 index 51ccd17..8a8086a 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt @@ -22,12 +22,14 @@ import androidx.appfunctions.AppFunctionSerializable */ @AppFunctionSerializable(isDescribedByKDoc = true) data class ContactSearchResult( - /** The unique identifier for the contact or group. */ - val endpointValue: String, + /** The human-readable name of the contact or group. */ + val contactDisplayName: 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, + val contactType: String, + /** The unique identifier of the endpoint. */ + val endpointValue: String, + /** The human-readable label/display name of the endpoint. */ + val endpointDisplayName: String, ) /** @@ -66,3 +68,27 @@ data class ChatGroup( /** List of members belonging to the group. */ val recipients: List, ) + +/** + * Represents a message returned in search results. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class Message( + /** The text content of the message. */ + val messageBody: String, + /** The timestamp when the message was sent or received. */ + val timestamp: Long, + /** The human-readable name of the sender. */ + val senderDisplayName: String, +) + +/** + * Represents the search results for a specific chat endpoint. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class MessagesSearchResult( + /** The unique identifier of the contact or group. */ + val endpointValue: String, + /** The list of relevant messages found in this chat. */ + val messages: List, +) 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 06a617e..702719b 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 @@ -18,7 +18,6 @@ package com.example.chatapp.data import com.example.chatapp.appfunctions.ChatGroup import com.example.chatapp.appfunctions.ContactSearchResult import com.example.chatapp.appfunctions.Recipient -import com.example.chatapp.appfunctions.Endpoint import javax.inject.Inject import javax.inject.Singleton From f88e00720223a8bc816288e0b40c03bcbed1a30b Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 21 Jul 2026 16:06:57 +0000 Subject: [PATCH 14/16] post merge cl\eanup --- .../chatapp/appfunctions/AppFunctionsTest.kt | 293 ------------------ .../chatapp/data/RecipientsRepository.kt | 176 ----------- .../BaseChatAppFunctionService.kt | 60 ++-- .../com/example/chatapp/appfunctions/Util.kt | 11 - 4 files changed, 31 insertions(+), 509 deletions(-) delete mode 100644 ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt delete mode 100644 ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt 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 deleted file mode 100644 index 1d8a498..0000000 --- a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionsTest.kt +++ /dev/null @@ -1,293 +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.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 -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.example.chatapp.data.CallManager -import com.example.chatapp.data.DisplayMessage -import com.example.chatapp.data.MessageRepository -import com.example.chatapp.data.RecipientsRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest -import org.junit.Assert -import org.junit.Test -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()) - - override fun getMessages(recipientId: String): Flow> { - return messages.map { it[recipientId] ?: emptyList() } - } - - override fun saveMessage( - recipientId: String, - message: DisplayMessage, - ) { - messages.value = - messages.value.toMutableMap().apply { - val current = this[recipientId] ?: emptyList() - this[recipientId] = listOf(message) + current - } - } - - override suspend fun send( - text: String, - recipientIds: List, - imageUris: List?, - ): String { - if (shouldFail) { - throw RuntimeException("Failed to send") - } - return "Message ID" - } - - override suspend fun sendToBot(text: String): String { - return "Bot response" - } - } - - private val messageRepository = MockMessageRepository() - - private val recipientsRepository = RecipientsRepository() - - private val callManager = CallManager(recipientsRepository) - - private val appFunctions = AppFunctions(messageRepository, recipientsRepository, callManager) - - @Test(expected = AppFunctionInvalidArgumentException::class) - fun searchContacts_returnsEmptyList() { - runBlocking { - appFunctions.searchContacts(testContext, "nonexistent", "INDIVIDUAL") - } - } - - @Test - fun searchContacts_returnsMatches() { - runBlocking { - val contacts = appFunctions.searchContacts(testContext, "Alice", "INDIVIDUAL") - Assert.assertEquals(1, contacts.size) - Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) - Assert.assertEquals("1", contacts[0].endpointValue) - Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) - } - } - - @Test - fun searchContacts_groups_returnsMatches() { - runBlocking { - val contacts = appFunctions.searchContacts(testContext, "Work", "GROUP") - Assert.assertEquals(1, contacts.size) - Assert.assertEquals("Work Friends", contacts[0].contactDisplayName) - Assert.assertEquals("GROUP", contacts[0].contactType) - Assert.assertEquals("g1", contacts[0].endpointValue) - Assert.assertEquals("Work Friends", contacts[0].endpointDisplayName) - } - } - - @Test(expected = AppFunctionInvalidArgumentException::class) - fun searchContacts_emptyQuery_fails() { - runBlocking { - appFunctions.searchContacts(testContext, "", "INDIVIDUAL") - } - } - - @Test - fun searchContacts_anyType_returnsMatches() { - runBlocking { - val contacts = appFunctions.searchContacts(testContext, "Alice", "ANY") - Assert.assertEquals(1, contacts.size) - Assert.assertEquals("Alice Smith", contacts[0].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) - Assert.assertEquals("1", contacts[0].endpointValue) - Assert.assertEquals("alice@example.com", contacts[0].endpointDisplayName) - } - } - - @Test - fun searchContacts_duplicateNamesReturnedAsSeparate() { - runBlocking { - val contacts = appFunctions.searchContacts(testContext, "Bob", "INDIVIDUAL") - Assert.assertEquals(2, contacts.size) - - Assert.assertEquals("Bob Johnson", contacts[0].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[0].contactType) - Assert.assertEquals("2", contacts[0].endpointValue) - Assert.assertEquals("bob@example.com", contacts[0].endpointDisplayName) - - Assert.assertEquals("Bob Johnson", contacts[1].contactDisplayName) - Assert.assertEquals("INDIVIDUAL", contacts[1].contactType) - Assert.assertEquals("7", contacts[1].endpointValue) - Assert.assertEquals("bob2@example.com", contacts[1].endpointDisplayName) - } - } - - @Test - fun sendMessage_validMessage_returnsSuccess() { - runTest { - val result = appFunctions.sendMessage(testContext, "1", "Hello") - Assert.assertEquals( - "Message sent to: Alice Smith.", - result, - ) - } - } - - @Test - fun sendMessage_withImageUris_success() { - runTest { - val result = - appFunctions.sendMessage( - testContext, - "1", - "Hello", - listOf(Uri.parse("content://media/1")), - ) - Assert.assertEquals( - "Message sent to: Alice Smith.", - result, - ) - } - } - - @Test - fun sendMessage_toGroup_success() { - runTest { - val result = appFunctions.sendMessage(testContext, "g1", "Hello") - Assert.assertEquals( - "Message sent to: Work Friends.", - result, - ) - } - } - - @Test(expected = AppFunctionInvalidArgumentException::class) - fun sendMessage_emptyContent_fails() { - runTest { - appFunctions.sendMessage(testContext, "1", "") - } - } - - @Test(expected = AppFunctionElementNotFoundException::class) - fun sendMessage_invalidRecipient_fails() { - runTest { - appFunctions.sendMessage(testContext, "nonexistent_id", "Hello") - } - } - - @Test(expected = AppFunctionAppUnknownException::class) - fun sendMessage_repositoryError_returnsError() { - runTest { - messageRepository.shouldFail = true - appFunctions.sendMessage(testContext, "1", "Hello") - } - } - - @Test - fun makeCall_returnsPendingIntent() { - runBlocking { - val pendingIntent = appFunctions.makeCall(testContext, endpointValue = "1") - Assert.assertNotNull(pendingIntent) - } - } - - - @Test(expected = AppFunctionElementNotFoundException::class) - fun makeCall_invalidEndpointValue_fails() { - runBlocking { - appFunctions.makeCall(testContext, endpointValue = "nonexistent_id") - } - } - - @Test - fun searchMessages_allChats_returnsMatches() { - runTest { - messageRepository.saveMessage("1", DisplayMessage("Hello Alice", 1000, isInbound = true)) - messageRepository.saveMessage("2", DisplayMessage("Hello Bob", 2000, isInbound = false)) - messageRepository.saveMessage("1", DisplayMessage("How are you?", 3000, isInbound = false)) - - val results = appFunctions.searchMessages(testContext, "Hello") - - Assert.assertEquals(2, results.size) - - val aliceResult = results.first { it.endpointValue == "1" } - Assert.assertEquals(1, aliceResult.messages.size) - Assert.assertEquals("Hello Alice", aliceResult.messages[0].messageBody) - Assert.assertEquals(1000L, aliceResult.messages[0].timestamp) - Assert.assertEquals("Alice Smith", aliceResult.messages[0].senderDisplayName) - - val bobResult = results.first { it.endpointValue == "2" } - Assert.assertEquals(1, bobResult.messages.size) - Assert.assertEquals("Hello Bob", bobResult.messages[0].messageBody) - Assert.assertEquals(2000L, bobResult.messages[0].timestamp) - Assert.assertEquals("Me", bobResult.messages[0].senderDisplayName) - } - } - - @Test - fun searchMessages_specificChat_returnsMatches() { - runTest { - messageRepository.saveMessage("1", DisplayMessage("Hello Alice", 1000, isInbound = true)) - messageRepository.saveMessage("2", DisplayMessage("Hello Bob", 2000, isInbound = false)) - - val results = appFunctions.searchMessages(testContext, "Hello", endpointValue = "1") - - Assert.assertEquals(1, results.size) - Assert.assertEquals("1", results[0].endpointValue) - Assert.assertEquals("Hello Alice", results[0].messages[0].messageBody) - } - } - - @Test - fun searchMessages_noMatches_returnsEmpty() { - runTest { - messageRepository.saveMessage("1", DisplayMessage("Hello Alice", 1000, isInbound = true)) - - val results = appFunctions.searchMessages(testContext, "Goodbye") - - Assert.assertTrue(results.isEmpty()) - } - } - - @Test(expected = AppFunctionInvalidArgumentException::class) - fun searchMessages_emptyQuery_fails() { - runTest { - appFunctions.searchMessages(testContext, "") - } - } - -} - 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 702719b..0000000 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/data/RecipientsRepository.kt +++ /dev/null @@ -1,176 +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.ChatGroup -import com.example.chatapp.appfunctions.ContactSearchResult -import com.example.chatapp.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 { - val matched = if (query.isNullOrBlank()) { - recipients - } else { - recipients.filter { - it.name.contains(query, ignoreCase = true) || - it.email.contains( - query, - ignoreCase = true, - ) - } - } - - val mapped = matched.map { - ContactSearchResult( - contactDisplayName = it.name, - contactType = "INDIVIDUAL", - endpointValue = it.id, - endpointDisplayName = it.email, - ) - } - - return if (query.isNullOrBlank()) { - mapped.take(maxCount) - } else { - mapped - } - } - - /** - * 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) - val groups = - searchGroups(query, maxCount).map { - ContactSearchResult( - contactDisplayName = it.name, - contactType = "GROUP", - endpointValue = it.id, - endpointDisplayName = 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/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt index 4694726..db56f78 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt @@ -57,19 +57,22 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { * 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". + * @param query Search string for contact name, email, or group name. Throws [AppFunctionInvalidArgumentException] if empty. + * @param contactType Filter results by entity type. Accepts "INDIVIDUAL", "GROUP", or "ANY". * @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. + * @throws AppFunctionInvalidArgumentException If query is empty or blank, an invalid contactType 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, + @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP", "ANY"]) + contactType: String, ): List { + if (query.isBlank()) { + throw AppFunctionInvalidArgumentException("Query cannot be empty") + } val recipients = - when (filterType) { + when (contactType) { "INDIVIDUAL" -> { recipientsRepository.searchRecipients(query, 3) } @@ -83,19 +86,19 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { ) } } - else -> { + "ANY" -> { recipientsRepository.searchAny(query, maxCount = 3) - .ifEmpty { - throw AppFunctionInvalidArgumentException( - "Only INDIVIDUAL or GROUP are accepted filter arguments.", - ) - } + } + else -> { + throw AppFunctionInvalidArgumentException( + "Invalid contactType: $contactType. Must be INDIVIDUAL, GROUP, or ANY.", + ) } } if (recipients.isEmpty()) { throw AppFunctionInvalidArgumentException( - "$filterType with name $query not found. Ask the user to clarify the name", + "$contactType with name $query not found. Ask the user to clarify the name", ) } return recipients @@ -108,17 +111,17 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { * @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. + * @return A human-readable message indicating the error or 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( + suspend fun sendMessage( endpointValue: String, messageBody: String, imageUris: List? = null, - ): Result { + ): String { if (messageBody.isBlank()) { throw AppFunctionInvalidArgumentException("Message body cannot be empty") } @@ -129,20 +132,19 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { "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: CancellationException) { - throw e - } catch (e: Exception) { - throw AppFunctionAppUnknownException("Failed to send message: ${e.message}") - } + try { + messageRepository.send( + text = messageBody, + recipientIds = listOf(endpointValue), + imageUris = imageUris?.map { it.toString() }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw AppFunctionAppUnknownException("Failed to send message: ${e.message}") + } - return Result(sentMessageId, "Message sent to: $displayName.") + return "Message sent to: $displayName." } /** 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 index 8a8086a..ac8bee1 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/Util.kt @@ -32,17 +32,6 @@ data class ContactSearchResult( val endpointDisplayName: 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. */ From b714884c793ee525d7e4645f9668c030e0c2bc87 Mon Sep 17 00:00:00 2001 From: Corina Date: Tue, 21 Jul 2026 16:13:57 +0000 Subject: [PATCH 15/16] spotless --- .../BaseChatAppFunctionService.kt | 19 ++++----- .../chatapp/data/RecipientsRepository.kt | 39 ++++++++++--------- 2 files changed, 30 insertions(+), 28 deletions(-) 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 index db56f78..a349325 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt @@ -238,21 +238,22 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { endpointValue = id, messages = matchingMessages.map { - val senderDisplayName = it.senderName - ?: if (it.isInbound) { - recipientsRepository.getRecipientById(id)?.name - ?: recipientsRepository.getGroupById(id)?.name - ?: "Other" - } else { - "Me" - } + val senderDisplayName = + it.senderName + ?: if (it.isInbound) { + recipientsRepository.getRecipientById(id)?.name + ?: recipientsRepository.getGroupById(id)?.name + ?: "Other" + } else { + "Me" + } Message( messageBody = it.content, timestamp = it.sentAt, senderDisplayName = senderDisplayName, ) }, - ) + ), ) } } 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 702719b..5b9b148 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 @@ -88,26 +88,28 @@ class RecipientsRepository query: String?, maxCount: Int, ): List { - val matched = if (query.isNullOrBlank()) { - recipients - } else { - recipients.filter { - it.name.contains(query, ignoreCase = true) || - it.email.contains( - query, - ignoreCase = true, - ) + val matched = + if (query.isNullOrBlank()) { + recipients + } else { + recipients.filter { + it.name.contains(query, ignoreCase = true) || + it.email.contains( + query, + ignoreCase = true, + ) + } } - } - val mapped = matched.map { - ContactSearchResult( - contactDisplayName = it.name, - contactType = "INDIVIDUAL", - endpointValue = it.id, - endpointDisplayName = it.email, - ) - } + val mapped = + matched.map { + ContactSearchResult( + contactDisplayName = it.name, + contactType = "INDIVIDUAL", + endpointValue = it.id, + endpointDisplayName = it.email, + ) + } return if (query.isNullOrBlank()) { mapped.take(maxCount) @@ -147,7 +149,6 @@ class RecipientsRepository * @return A unified list of [ContactSearchResult] containing both individuals and groups. */ - fun searchAny( query: String?, maxCount: Int, From f8ea41fc8edfc295e3f64242bb9b11b1b5685256 Mon Sep 17 00:00:00 2001 From: Corina Date: Wed, 22 Jul 2026 10:36:38 +0000 Subject: [PATCH 16/16] helper fn --- .../BaseChatAppFunctionService.kt | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) 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 index a349325..3ed0ead 100644 --- a/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt +++ b/ChatApp/shared/src/main/kotlin/com/example/chatapp/appfunctions/BaseChatAppFunctionService.kt @@ -27,6 +27,7 @@ import androidx.appfunctions.AppFunctionService import androidx.appfunctions.AppFunctionServiceEntryPoint import androidx.appfunctions.AppFunctionStringValueConstraint import com.example.chatapp.data.CallManager +import com.example.chatapp.data.DisplayMessage import com.example.chatapp.data.MessageRepository import com.example.chatapp.data.RecipientsRepository import com.example.chatapp.data.WallpaperRepository @@ -238,15 +239,7 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { endpointValue = id, messages = matchingMessages.map { - val senderDisplayName = - it.senderName - ?: if (it.isInbound) { - recipientsRepository.getRecipientById(id)?.name - ?: recipientsRepository.getGroupById(id)?.name - ?: "Other" - } else { - "Me" - } + val senderDisplayName = getSenderDisplayName(it, id) Message( messageBody = it.content, timestamp = it.sentAt, @@ -260,4 +253,18 @@ abstract class BaseChatAppFunctionService : AppFunctionService() { return results } + + private fun getSenderDisplayName( + message: DisplayMessage, + endpointId: String, + ): String { + return message.senderName + ?: if (message.isInbound) { + recipientsRepository.getRecipientById(endpointId)?.name + ?: recipientsRepository.getGroupById(endpointId)?.name + ?: "Other" + } else { + "Me" + } + } }