Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -89,8 +89,7 @@ class AppFunctionInstrumentationTest {
val successResponse = assertIs<ExecuteAppFunctionResponse.Success>(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
Expand All @@ -109,12 +108,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,
Expand Down Expand Up @@ -151,7 +150,7 @@ class AppFunctionInstrumentationTest {
getRecipientsFunctionMetadata.components,
)
.setString("query", "Alice")
.setString("filterType", "INDIVIDUAL")
.setString("contactType", "INDIVIDUAL")
.build(),
)

Expand All @@ -165,7 +164,12 @@ class AppFunctionInstrumentationTest {
?.map { it.deserialize(ContactSearchResult::class.java) },
)
.containsExactly(
ContactSearchResult(endpointValue = "1", endpointType = "INDIVIDUAL", displayName = "Alice Smith"),
AppFunctions.ContactSearchResult(
contactDisplayName = "Alice Smith",
contactType = "INDIVIDUAL",
endpointValue = "1",
endpointDisplayName = "alice@example.com",
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -56,50 +57,48 @@ 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<ContactSearchResult> {
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. 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
Expand All @@ -112,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<Uri>? = null,
): Result {
): String {
if (messageBody.isBlank()) {
throw AppFunctionInvalidArgumentException("Message body cannot be empty")
}
Expand All @@ -133,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."
}

/**
Expand Down Expand Up @@ -202,4 +200,64 @@ 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<MessagesSearchResult> {
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<MessagesSearchResult>()

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +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,
)

/**
* 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,
val contactType: String,
/** The unique identifier of the endpoint. */
val endpointValue: String,
/** The human-readable label/display name of the endpoint. */
val endpointDisplayName: String,
)

/**
Expand Down Expand Up @@ -66,3 +57,27 @@ data class ChatGroup(
/** List of members belonging to the group. */
val recipients: List<Recipient>,
)

/**
* 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<Message>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,34 @@ class RecipientsRepository
fun searchRecipients(
query: String?,
maxCount: Int,
): List<Recipient> {
if (query.isNullOrBlank()) {
// TODO:Return most recently contacted.
return recipients.take(maxCount)
}
): List<ContactSearchResult> {
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
}
}

Expand Down Expand Up @@ -132,24 +148,19 @@ 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<ContactSearchResult> {
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<ContactSearchResult>().apply {
Expand Down
Loading