diff --git a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionInstrumentationTest.kt b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/appfunctions/AppFunctionInstrumentationTest.kt index b88194f..1f5b120 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 @@ -72,12 +72,12 @@ class AppFunctionInstrumentationTest { ) .first() .flatMap { it.appFunctions } - .single { it.id == AppFunctionsIds.SEND_ID } + .single { it.id == AppFunctionsIds.SEND_MESSAGE_ID } val testRecipient = recipientsRepository.getAllRecipients().first() val request = ExecuteAppFunctionRequest( targetPackageName = context.packageName, - AppFunctionsIds.SEND_ID, + AppFunctionsIds.SEND_MESSAGE_ID, AppFunctionData.Builder( sendMessageFunctionMetadata.parameters, sendMessageFunctionMetadata.components, @@ -91,8 +91,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 @@ -111,12 +110,12 @@ class AppFunctionInstrumentationTest { ) .first() .flatMap { it.appFunctions } - .single { it.id == AppFunctionsIds.SEND_ID } + .single { it.id == AppFunctionsIds.SEND_MESSAGE_ID } val testRecipient = recipientsRepository.getAllRecipients().first() val request = ExecuteAppFunctionRequest( targetPackageName = context.packageName, - AppFunctionsIds.SEND_ID, + AppFunctionsIds.SEND_MESSAGE_ID, AppFunctionData.Builder( sendMessageFunctionMetadata.parameters, sendMessageFunctionMetadata.components, @@ -153,7 +152,7 @@ class AppFunctionInstrumentationTest { getRecipientsFunctionMetadata.components, ) .setString("query", "Alice") - .setString("filterType", "INDIVIDUAL") + .setString("contactType", "INDIVIDUAL") .build(), ) @@ -167,7 +166,12 @@ class AppFunctionInstrumentationTest { ?.map { it.deserialize(AppFunctions.ContactSearchResult::class.java) }, ) .containsExactly( - AppFunctions.ContactSearchResult(endpointValue = "1", endpointType = "INDIVIDUAL", displayName = "Alice Smith"), + AppFunctions.ContactSearchResult( + contactDisplayName = "Alice Smith", + contactType = "INDIVIDUAL", + 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 d4095e3..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 @@ -99,7 +99,10 @@ class AppFunctionsTest { runBlocking { val contacts = appFunctions.searchContacts(testContext, "Alice", "INDIVIDUAL") Assert.assertEquals(1, contacts.size) - Assert.assertEquals("Alice Smith", contacts[0].displayName) + 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) } } @@ -108,95 +111,108 @@ class AppFunctionsTest { runBlocking { val contacts = appFunctions.searchContacts(testContext, "Work", "GROUP") Assert.assertEquals(1, contacts.size) - Assert.assertEquals("Work Friends", contacts[0].displayName) + 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 - 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") } } @Test fun searchContacts_anyType_returnsMatches() { runBlocking { - // "searchAny" branch (when filterType is not INDIVIDUAL or GROUP) val contacts = appFunctions.searchContacts(testContext, "Alice", "ANY") Assert.assertEquals(1, contacts.size) - Assert.assertEquals("Alice Smith", contacts[0].displayName) + 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 send_validMessage_returnsSuccess() { + fun sendMessage_validMessage_returnsSuccess() { runTest { - val result = appFunctions.send(testContext, "1", "Hello") + val result = appFunctions.sendMessage(testContext, "1", "Hello") Assert.assertEquals( - AppFunctions.Result( - "Message ID", - "Message sent to: Alice Smith.", - ), + "Message sent to: Alice Smith.", result, ) } } @Test - fun send_withImageUris_success() { + fun sendMessage_withImageUris_success() { runTest { val result = - appFunctions.send( + appFunctions.sendMessage( testContext, "1", "Hello", listOf(Uri.parse("content://media/1")), ) Assert.assertEquals( - AppFunctions.Result( - "Message ID", - "Message sent to: Alice Smith.", - ), + "Message sent to: Alice Smith.", result, ) } } @Test - fun send_toGroup_success() { + fun sendMessage_toGroup_success() { runTest { - val result = appFunctions.send(testContext, "g1", "Hello") + val result = appFunctions.sendMessage(testContext, "g1", "Hello") Assert.assertEquals( - AppFunctions.Result( - "Message ID", - "Message sent to: Work Friends.", - ), + "Message sent to: Work Friends.", result, ) } } @Test(expected = AppFunctionInvalidArgumentException::class) - fun send_emptyContent_fails() { + fun sendMessage_emptyContent_fails() { runTest { - appFunctions.send(testContext, "1", "") + appFunctions.sendMessage(testContext, "1", "") } } @Test(expected = AppFunctionElementNotFoundException::class) - fun send_invalidRecipient_fails() { + fun sendMessage_invalidRecipient_fails() { runTest { - appFunctions.send(testContext, "nonexistent_id", "Hello") + appFunctions.sendMessage(testContext, "nonexistent_id", "Hello") } } @Test(expected = AppFunctionAppUnknownException::class) - fun send_repositoryError_returnsError() { + fun sendMessage_repositoryError_returnsError() { runTest { messageRepository.shouldFail = true - appFunctions.send(testContext, "1", "Hello") + appFunctions.sendMessage(testContext, "1", "Hello") } } @@ -216,4 +232,62 @@ class AppFunctionsTest { } } + @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 index fdd153e..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 @@ -87,18 +87,32 @@ 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 mapped = matched.map { + ContactSearchResult( + contactDisplayName = it.name, + contactType = "INDIVIDUAL", + endpointValue = it.id, + endpointDisplayName = it.email, + ) + } + + return if (query.isNullOrBlank()) { + mapped.take(maxCount) + } else { + mapped } } @@ -132,24 +146,20 @@ 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, ): List { - val individuals = - searchRecipients(query, maxCount).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - displayName = it.name, - ) - } + val individuals = searchRecipients(query, maxCount) val groups = searchGroups(query, maxCount).map { ContactSearchResult( + contactDisplayName = it.name, + contactType = "GROUP", endpointValue = it.id, - endpointType = "GROUP", - displayName = it.name, + endpointDisplayName = it.name, ) } return mutableListOf().apply { diff --git a/ChatApp/shared/build.gradle.kts b/ChatApp/shared/build.gradle.kts index 7d93e0f..4424dbd 100644 --- a/ChatApp/shared/build.gradle.kts +++ b/ChatApp/shared/build.gradle.kts @@ -50,4 +50,5 @@ dependencies { implementation(libs.androidx.appfunctions.service) ksp(libs.androidx.appfunctions.compiler) + } 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 81a6574..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 @@ -29,6 +29,7 @@ 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. @@ -44,50 +45,48 @@ 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 filterType Filter results by entity type. Accepts "INDIVIDUAL" or "GROUP". + * @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"]) - 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).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, ) } } - else -> { + "ANY" -> { recipientsRepository.searchAny(query, maxCount = 3) - .ifEmpty { - throw AppFunctionInvalidArgumentException( - "Only INDIVIDUAL or GROUP are accepted filter arguments.", - ) - } + } + else -> { + throw AppFunctionInvalidArgumentException( + "Invalid contactType: $contactType", + ) } } 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 @@ -100,14 +99,15 @@ 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 send( + suspend fun sendMessage( appFunctionContext: AppFunctionContext, endpointValue: String, messageBody: String, imageUris: List? = null, - ): Result { + ): String { if (messageBody.isBlank()) { throw AppFunctionInvalidArgumentException("Message body cannot be empty") } @@ -131,7 +131,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 Result(sentMessageId, "Message sent to: $displayName.") + return "Message sent to: $displayName." } /** @@ -165,25 +165,80 @@ 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. 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 unique identifier. */ - val endpointValue: String, - /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */ - val endpointType: String, /** The human-readable name of the contact or group. */ - val displayName: String, + 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, ) - /** Result of a message sending operation. */ - @AppFunctionSerializable(isDescribedByKDoc = true) - data class Result( - /** The unique identifier for the successfully sent message. */ - val messageId: String, - /** A human-readable status message indicating success. */ - val message: String, - ) + /** Represents a recipient of a message. */ @AppFunctionSerializable(isDescribedByKDoc = true) @@ -206,4 +261,24 @@ class AppFunctions /** 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 fdd153e..85b33da 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 @@ -87,18 +87,32 @@ 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 mapped = matched.map { + ContactSearchResult( + contactDisplayName = it.name, + contactType = "INDIVIDUAL", + endpointValue = it.id, + endpointDisplayName = it.email, + ) + } + + return if (query.isNullOrBlank()) { + mapped.take(maxCount) + } else { + mapped } } @@ -132,24 +146,20 @@ 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, ): List { - val individuals = - searchRecipients(query, maxCount).map { - ContactSearchResult( - endpointValue = it.id, - endpointType = "INDIVIDUAL", - displayName = it.name, - ) - } + val individuals = searchRecipients(query, maxCount) val groups = searchGroups(query, maxCount).map { ContactSearchResult( + contactDisplayName = it.name, + contactType = "GROUP", endpointValue = it.id, - endpointType = "GROUP", - displayName = it.name, + endpointDisplayName = it.name, ) } return mutableListOf().apply { diff --git a/ChatApp/wear/build.gradle.kts b/ChatApp/wear/build.gradle.kts index 8c0cd37..1799d71 100644 --- a/ChatApp/wear/build.gradle.kts +++ b/ChatApp/wear/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.androidx.appfunctions.service) ksp(libs.androidx.appfunctions.compiler) + } // AppFunctions ksp option