From c3635a00da2a9b9c85bbe0b1283fc2122dd93850 Mon Sep 17 00:00:00 2001 From: Kasia Krejszeff Date: Mon, 5 Oct 2026 19:10:50 +0000 Subject: [PATCH 1/2] ChatApp: fix compilation of instrumented tests The androidTest sources did not compile: - HiltTestRunner uses HiltTestRunner_Application, which Hilt generates only when its compiler runs on the androidTest sources. Add kspAndroidTest(libs.hilt.compiler). - AppFunctionInstrumentationTest used AppFunctions.ContactSearchResult, which does not exist. Use ContactSearchResult. CI does not compile these sources: the instrumentation test steps in .github/workflows/chatapp.yaml are commented out. Test: ./gradlew :app:assembleDebugAndroidTest --- ChatApp/app/build.gradle.kts | 1 + .../chatapp/appfunctions/AppFunctionInstrumentationTest.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ChatApp/app/build.gradle.kts b/ChatApp/app/build.gradle.kts index f6e71559..77513208 100644 --- a/ChatApp/app/build.gradle.kts +++ b/ChatApp/app/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) androidTestImplementation(libs.kotlinTest) androidTestImplementation(libs.truth) androidTestImplementation(platform(libs.androidx.compose.bom)) 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 b7a3c1ae..8988623e 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 @@ -164,7 +164,7 @@ class AppFunctionInstrumentationTest { ?.map { it.deserialize(ContactSearchResult::class.java) }, ) .containsExactly( - AppFunctions.ContactSearchResult( + ContactSearchResult( contactDisplayName = "Alice Smith", contactType = "INDIVIDUAL", endpointValue = "1", From bdd2edf6e078dff3efc7477337d0913d44f4695a Mon Sep 17 00:00:00 2001 From: Kasia Krejszeff Date: Thu, 1 Oct 2026 17:53:16 +0000 Subject: [PATCH 2/2] ChatApp: pre-fill the chat input from a deep-link draft Add an optional `draft` query parameter to the chat route and its deep link, so a link such as app://com.example.chatapp/chat/1?draft=Hello%20Alice%21 opens the chat with Alice and puts "Hello Alice!" in the input box. The draft is never sent automatically: the user reviews it and taps Send. fetchSendMessageDetails (#57) already returns this link as editMessageUri, so the user can edit the message in the app before sending it; this change makes the app handle the draft. Links without a draft, and existing navigate("chat/$id") calls, behave as before. ChatDeepLinkTest checks that the link resolves to MainActivity, that the draft is pre-filled (including non-ASCII text, a line break and characters that need percent-encoding), and that a link without a draft opens the chat with an empty input. Test: ./gradlew :app:connectedDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.class=com.example.chatapp.ChatDeepLinkTest adb shell am start -a android.intent.action.VIEW -p com.example.chatapp \ -d 'app://com.example.chatapp/chat/1?draft=Hello%20Alice%21' --- .../com/example/chatapp/ChatDeepLinkTest.kt | 124 ++++++++++++++++++ .../com/example/chatapp/MainActivity.kt | 21 ++- .../chatapp/uicomponents/ChatScreen.kt | 3 +- 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 ChatApp/app/src/androidTest/kotlin/com/example/chatapp/ChatDeepLinkTest.kt diff --git a/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/ChatDeepLinkTest.kt b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/ChatDeepLinkTest.kt new file mode 100644 index 00000000..d29e6aff --- /dev/null +++ b/ChatApp/app/src/androidTest/kotlin/com/example/chatapp/ChatDeepLinkTest.kt @@ -0,0 +1,124 @@ +/* + * 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 + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.chatapp.appfunctions.Recipient +import com.example.chatapp.data.RecipientsRepository +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +/** + * Tests that chat deep links, such as the `editMessageUri` returned by `fetchSendMessageDetails`, + * open the chat screen with the optional `draft` query parameter pre-filled in the message input. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class ChatDeepLinkTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createEmptyComposeRule() + + @Inject lateinit var recipientsRepository: RecipientsRepository + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var recipient: Recipient + + @Before + fun setUp() { + hiltRule.inject() + recipient = recipientsRepository.getAllRecipients().first() + } + + @Test + fun chatLink_resolvesToMainActivity() { + val intent = + Intent(Intent.ACTION_VIEW, chatLink(recipient.id, draft = "Hi")) + .setPackage(context.packageName) + + val activity = intent.resolveActivity(context.packageManager) + + assertThat(activity?.className).isEqualTo(MainActivity::class.java.name) + } + + @Test + fun chatLinkWithDraft_prefillsMessageInput() { + launch(chatLink(recipient.id, draft = "Running 10 minutes late")).use { + composeRule.onNodeWithText(recipient.name).assertIsDisplayed() + assertThat(messageInputText()).isEqualTo("Running 10 minutes late") + } + } + + @Test + fun chatLinkWithoutDraft_leavesMessageInputEmpty() { + launch(chatLink(recipient.id, draft = null)).use { + composeRule.onNodeWithText(recipient.name).assertIsDisplayed() + assertThat(messageInputText()).isEmpty() + } + } + + @Test + fun chatLinkWithDraft_keepsSpecialCharacters() { + // Characters that must be percent-encoded in a URI, non-ASCII text and a line break. + val draft = "Hello 👋\nA&B=C? 100% + \"quotes\" #1" + + launch(chatLink(recipient.id, draft)).use { + assertThat(messageInputText()).isEqualTo(draft) + } + } + + /** Builds the link the same way as `buildEditMessageUri` in `BaseChatAppFunctionService`. */ + private fun chatLink( + recipientId: String, + draft: String?, + ): Uri = + Uri.Builder() + .scheme("app") + .authority("com.example.chatapp") + .appendPath("chat") + .appendPath(recipientId) + .apply { if (draft != null) appendQueryParameter("draft", draft) } + .build() + + private fun launch(link: Uri): ActivityScenario = + ActivityScenario.launch(Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java)) + + private fun messageInputText(): String = + composeRule + .onNode(hasSetTextAction()) + .fetchSemanticsNode() + .config[SemanticsProperties.EditableText] + .text +} diff --git a/ChatApp/app/src/main/kotlin/com/example/chatapp/MainActivity.kt b/ChatApp/app/src/main/kotlin/com/example/chatapp/MainActivity.kt index ea7f42ad..51278728 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/MainActivity.kt +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/MainActivity.kt @@ -76,13 +76,28 @@ class MainActivity : ComponentActivity() { ) } composable( - route = "chat/{recipientId}", - arguments = listOf(navArgument("recipientId") { type = NavType.StringType }), - deepLinks = listOf(navDeepLink { uriPattern = "app://com.example.chatapp/chat/{recipientId}" }), + route = "chat/{recipientId}?draft={draft}", + arguments = + listOf( + navArgument("recipientId") { type = NavType.StringType }, + navArgument("draft") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + deepLinks = + listOf( + navDeepLink { + uriPattern = "app://com.example.chatapp/chat/{recipientId}?draft={draft}" + }, + ), ) { backStackEntry -> val recipientId = backStackEntry.arguments?.getString("recipientId") ?: "bot" + val draft = backStackEntry.arguments?.getString("draft") ChatScreen( recipientId = recipientId, + initialDraft = draft, onCallClick = { navController.navigate("call/$recipientId") }, onBackClick = { navController.popBackStack() }, ) diff --git a/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/ChatScreen.kt b/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/ChatScreen.kt index 1ba9981a..3d56be52 100644 --- a/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/ChatScreen.kt +++ b/ChatApp/app/src/main/kotlin/com/example/chatapp/uicomponents/ChatScreen.kt @@ -78,6 +78,7 @@ import com.example.chatapp.util.linkifyString @Composable fun ChatScreen( recipientId: String, + initialDraft: String? = null, viewModel: ChatViewModel = hiltViewModel( key = recipientId, @@ -91,7 +92,7 @@ fun ChatScreen( val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var message by rememberSaveable { mutableStateOf("") } + var message by rememberSaveable { mutableStateOf(initialDraft.orEmpty()) } Box(modifier = Modifier.fillMaxSize()) { uiState.wallpaperPath?.let { path ->