From 1dedd2ed9eac2d9728f2382e2023529aff930f76 Mon Sep 17 00:00:00 2001 From: Google Maps SDK Team Date: Sun, 20 Sep 2026 23:01:26 -0700 Subject: [PATCH] No public description PiperOrigin-RevId: 985036136 --- .github/workflows/cleanup-stale-prs.yml | 84 ++ .github/workflows/python-ci.yml | 3 + .github/workflows/web-ci.yml | 1 + .github/workflows/zizmor.yml | 18 +- README.md | 1 - client/android/app/build.gradle | 21 +- .../app/src/androidTest/AndroidManifest.xml | 2 - .../java/com/example/maui/MainActivityTest.kt | 132 +-- .../android/app/src/main/AndroidManifest.xml | 2 - .../canned_responses/EdgewaterHotel.json | 262 ++++++ .../canned_responses/KirklandCommute.json | 1 + .../canned_responses/LondonItinerary.json | 823 ++++++++++++++++++ .../canned_responses/SLUSaladsDirections.json | 406 +++++++++ .../canned_responses/SeattleCoffeeShops.json | 502 +++++++++++ .../main/assets/canned_responses/mapping.json | 10 +- .../assets/canned_responses/prompt_1.json | 115 --- .../assets/canned_responses/prompt_2.json | 69 -- .../assets/canned_responses/prompt_3.json | 69 -- .../assets/canned_responses/prompt_4.json | 98 --- .../assets/canned_responses/prompt_5.json | 203 ----- .../main/java/com/example/maui/ChatAdapter.kt | 137 --- .../main/java/com/example/maui/ChatMessage.kt | 20 +- .../java/com/example/maui/MainActivity.kt | 472 +++++++--- .../com/example/maui/data/ChatRepository.kt | 241 ++--- .../java/com/example/maui/ui/ChatViewModel.kt | 276 +++--- .../app/src/main/res/layout/activity_main.xml | 116 --- .../app/src/main/res/layout/item_loading.xml | 36 - .../res/layout/item_maui_gmp_a2ui_view.xml | 27 - .../app/src/main/res/layout/item_text.xml | 33 - .../ic_launcher.xml | 0 .../ic_launcher_round.xml | 0 .../app/src/main/res/values/strings.xml | 7 + client/ios/A2UI-Example-Info.plist | 2 + .../A2UIWebViewAssertions.swift | 215 +++++ .../A2UI_ExampleUITests.swift | 167 ++-- client/ios/ChatService.swift | 178 ++-- client/ios/ChatServiceTests.swift | 65 ++ client/ios/ChatViewModel.swift | 52 +- client/web/react/src/App.css | 26 + client/web/react/src/App.tsx | 39 + 40 files changed, 3491 insertions(+), 1440 deletions(-) create mode 100644 .github/workflows/cleanup-stale-prs.yml create mode 100644 client/android/app/src/main/assets/canned_responses/EdgewaterHotel.json create mode 100644 client/android/app/src/main/assets/canned_responses/KirklandCommute.json create mode 100644 client/android/app/src/main/assets/canned_responses/LondonItinerary.json create mode 100644 client/android/app/src/main/assets/canned_responses/SLUSaladsDirections.json create mode 100644 client/android/app/src/main/assets/canned_responses/SeattleCoffeeShops.json delete mode 100644 client/android/app/src/main/assets/canned_responses/prompt_1.json delete mode 100644 client/android/app/src/main/assets/canned_responses/prompt_2.json delete mode 100644 client/android/app/src/main/assets/canned_responses/prompt_3.json delete mode 100644 client/android/app/src/main/assets/canned_responses/prompt_4.json delete mode 100644 client/android/app/src/main/assets/canned_responses/prompt_5.json delete mode 100644 client/android/app/src/main/java/com/example/maui/ChatAdapter.kt delete mode 100644 client/android/app/src/main/res/layout/activity_main.xml delete mode 100644 client/android/app/src/main/res/layout/item_loading.xml delete mode 100644 client/android/app/src/main/res/layout/item_maui_gmp_a2ui_view.xml delete mode 100644 client/android/app/src/main/res/layout/item_text.xml rename client/android/app/src/main/res/{mipmap => mipmap-anydpi-v26}/ic_launcher.xml (100%) rename client/android/app/src/main/res/{mipmap => mipmap-anydpi-v26}/ic_launcher_round.xml (100%) create mode 100644 client/ios/A2UI-ExampleUITests/A2UIWebViewAssertions.swift create mode 100644 client/ios/ChatServiceTests.swift diff --git a/.github/workflows/cleanup-stale-prs.yml b/.github/workflows/cleanup-stale-prs.yml new file mode 100644 index 0000000..d238b98 --- /dev/null +++ b/.github/workflows/cleanup-stale-prs.yml @@ -0,0 +1,84 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# http://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. + +name: Cleanup Stale Draft PRs + +on: + schedule: + - cron: '0 2 * * *' # Daily at 02:00 UTC + workflow_dispatch: + inputs: + older_than_days: + description: 'Close draft PRs older than N days' + required: false + default: '3' + type: string + dry_run: + description: 'Dry run (simulate without closing PRs or deleting branches)' + required: false + default: false + type: boolean + +permissions: + pull-requests: write + contents: write + +jobs: + cleanup: + name: Cleanup Draft PRs + # zizmor: ignore[unpinned-images] + runs-on: ubuntu-latest + steps: + - name: Close stale draft PRs and delete branches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + INPUT_DAYS: ${{ inputs.older_than_days }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + DAYS="${INPUT_DAYS:-3}" + DRY_RUN="${INPUT_DRY_RUN:-false}" + + echo "Searching for open draft PRs with head branch matching 'test_*' older than $DAYS day(s)..." + + CUTOFF_EPOCH=$(date -d "$DAYS days ago" +%s) + echo "Cutoff timestamp: $CUTOFF_EPOCH ($(date -d "@$CUTOFF_EPOCH" --utc --iso-8601=seconds))" + + PRS_JSON=$(gh pr list --repo "$GH_REPO" --state open --draft --json number,headRefName,updatedAt) + + echo "$PRS_JSON" | jq -c '.[]' | while read -r pr; do + PR_NUMBER=$(echo "$pr" | jq -r '.number') + HEAD_REF=$(echo "$pr" | jq -r '.headRefName') + UPDATED_AT=$(echo "$pr" | jq -r '.updatedAt') + + # Only target Copybara presubmit branches (prefix test_) + if [[ ! "$HEAD_REF" =~ ^test_ ]]; then + echo "Skipping PR #$PR_NUMBER (head branch '$HEAD_REF' does not match 'test_*')" + continue + fi + + PR_EPOCH=$(date -d "$UPDATED_AT" +%s) + if [ "$PR_EPOCH" -lt "$CUTOFF_EPOCH" ]; then + echo "PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) is older than $DAYS day(s)." + if [ "$DRY_RUN" = "true" ]; then + echo "[DRY RUN] Would close PR #$PR_NUMBER and delete branch '$HEAD_REF'" + else + echo "Closing PR #$PR_NUMBER and deleting branch '$HEAD_REF'..." + gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR and cleaning up branch." --delete-branch || \ + gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR." + fi + else + echo "Keeping PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) - active within $DAYS day(s)." + fi + done diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 84f8788..6704f4d 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -22,7 +22,10 @@ on: jobs: build: + name: Python CI / build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 143ee5c..e6e02e8 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -22,6 +22,7 @@ on: jobs: build: + name: Web CI / build runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 1d45775..43ab814 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# http://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. + name: Zizmor on: @@ -23,4 +37,6 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1 \ No newline at end of file + uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1 + with: + args: --ignore insufficient-cooldown \ No newline at end of file diff --git a/README.md b/README.md index 89e6b04..8a2036c 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,6 @@ Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To Your API Key must have the following APIs enabled in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials): -* Geocoding API * Maps JavaScript API * Places UI Kit * Routes API diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index 69a0d83..83a29a2 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -35,6 +35,7 @@ android { buildFeatures { buildConfig true + compose true } signingConfigs { @@ -74,17 +75,29 @@ kotlin { } dependencies { + def composeBom = platform('androidx.compose:compose-bom:2024.02.00') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.activity:activity-compose:1.8.2' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + def lifecycleVersion = '2.8.7' + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" + implementation 'androidx.tracing:tracing-ktx:1.2.0' implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7' implementation 'com.squareup.okhttp3:okhttp:5.3.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' implementation 'com.google.android.libraries.mapsplatform.a2ui:GoogleMapsA2UI:0.1.0' } diff --git a/client/android/app/src/androidTest/AndroidManifest.xml b/client/android/app/src/androidTest/AndroidManifest.xml index c0d6a5b..f545a32 100644 --- a/client/android/app/src/androidTest/AndroidManifest.xml +++ b/client/android/app/src/androidTest/AndroidManifest.xml @@ -17,8 +17,6 @@ - - () @Test fun testSeattleCoffeeShopsCannedResponse() { - onView(withId(R.id.editTextMessage)) - .perform( - replaceText("Show me 5 coffee shops near South Lake Union in Seattle"), - closeSoftKeyboard(), - ) - onView(withId(R.id.buttonSend)).perform(click()) - Thread.sleep(7000) + composeTestRule + .onNodeWithTag("editTextMessage") + .performTextReplacement("Show me 5 coffee shops near South Lake Union in Seattle") + composeTestRule.onNodeWithTag("buttonSend").performClick() + composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithTag("gmpA2UIView").fetchSemanticsNodes().isNotEmpty() + } - // Verify that the A2UIView container (which renders the mock JSON) is displayed - onView(withId(R.id.gmpA2UIView)).check(matches(isDisplayed())) + // Verify that the A2UIView container (which renders the mock JSON) exists + composeTestRule.onNodeWithTag("gmpA2UIView").assertExists() - // Scroll the recycler view to see the full content - onView(withId(R.id.recyclerView)).perform(swipeUp()) + // Scroll the message list to see the full content + composeTestRule.onNodeWithTag("recyclerView").performScrollToIndex(1) // Pause to let the observer see the final state before the next test starts Thread.sleep(2000) @@ -57,16 +54,19 @@ class MainActivityTest { @Test fun testEdgewaterHotelCannedResponse() { - onView(withId(R.id.editTextMessage)) - .perform(replaceText("Is the Edgewater Hotel in Seattle a good hotel?"), closeSoftKeyboard()) - onView(withId(R.id.buttonSend)).perform(click()) - Thread.sleep(7000) + composeTestRule + .onNodeWithTag("editTextMessage") + .performTextReplacement("Is the Edgewater Hotel in Seattle a good hotel?") + composeTestRule.onNodeWithTag("buttonSend").performClick() + composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithTag("gmpA2UIView").fetchSemanticsNodes().isNotEmpty() + } - // Verify that the A2UIView container (which renders the mock JSON) is displayed - onView(withId(R.id.gmpA2UIView)).check(matches(isDisplayed())) + // Verify that the A2UIView container (which renders the mock JSON) exists + composeTestRule.onNodeWithTag("gmpA2UIView").assertExists() - // Scroll the recycler view to see the full content - onView(withId(R.id.recyclerView)).perform(swipeUp()) + // Scroll the message list to see the full content + composeTestRule.onNodeWithTag("recyclerView").performScrollToIndex(1) // Pause to let the observer see the final state before the next test starts Thread.sleep(2000) @@ -74,21 +74,21 @@ class MainActivityTest { @Test fun testKirklandCommuteCannedResponse() { - onView(withId(R.id.editTextMessage)) - .perform( - replaceText( - "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?" - ), - closeSoftKeyboard(), + composeTestRule + .onNodeWithTag("editTextMessage") + .performTextReplacement( + "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?" ) - onView(withId(R.id.buttonSend)).perform(click()) - Thread.sleep(7000) + composeTestRule.onNodeWithTag("buttonSend").performClick() + composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithTag("gmpA2UIView").fetchSemanticsNodes().isNotEmpty() + } - // Verify that the A2UIView container (which renders the mock JSON) is displayed - onView(withId(R.id.gmpA2UIView)).check(matches(isDisplayed())) + // Verify that the A2UIView container (which renders the mock JSON) exists + composeTestRule.onNodeWithTag("gmpA2UIView").assertExists() - // Scroll the recycler view to see the full content - onView(withId(R.id.recyclerView)).perform(swipeUp()) + // Scroll the message list to see the full content + composeTestRule.onNodeWithTag("recyclerView").performScrollToIndex(1) // Pause to let the observer see the final state before the next test starts Thread.sleep(2000) @@ -96,21 +96,21 @@ class MainActivityTest { @Test fun testSLUSaladsCannedResponse() { - onView(withId(R.id.editTextMessage)) - .perform( - replaceText( - "Show me 5 lunch restaurants with Salads in South Lake Union. Give me directions to the 2nd one (starting from the Google South Lake Union WLK building)" - ), - closeSoftKeyboard(), + composeTestRule + .onNodeWithTag("editTextMessage") + .performTextReplacement( + "Show me 5 lunch restaurants with Salads in South Lake Union. Give me directions to the 2nd one (starting from the Google South Lake Union WLK building)" ) - onView(withId(R.id.buttonSend)).perform(click()) - Thread.sleep(7000) + composeTestRule.onNodeWithTag("buttonSend").performClick() + composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithTag("gmpA2UIView").fetchSemanticsNodes().isNotEmpty() + } - // Verify that the A2UIView container (which renders the mock JSON) is displayed - onView(withId(R.id.gmpA2UIView)).check(matches(isDisplayed())) + // Verify that the A2UIView container (which renders the mock JSON) exists + composeTestRule.onNodeWithTag("gmpA2UIView").assertExists() - // Scroll the recycler view to see the full content - onView(withId(R.id.recyclerView)).perform(swipeUp()) + // Scroll the message list to see the full content + composeTestRule.onNodeWithTag("recyclerView").performScrollToIndex(1) // Pause to let the observer see the final state before the next test starts Thread.sleep(2000) @@ -118,19 +118,19 @@ class MainActivityTest { @Test fun testLondonItineraryCannedResponse() { - onView(withId(R.id.editTextMessage)) - .perform( - replaceText("Give me a 3 day itinerary for a family of 3 traveling to London"), - closeSoftKeyboard(), - ) - onView(withId(R.id.buttonSend)).perform(click()) - Thread.sleep(7000) - - // Verify that the A2UIView container (which renders the mock JSON) is displayed - onView(withId(R.id.gmpA2UIView)).check(matches(isDisplayed())) - - // Scroll the recycler view to see the full content - onView(withId(R.id.recyclerView)).perform(swipeUp()) + composeTestRule + .onNodeWithTag("editTextMessage") + .performTextReplacement("Give me a 3 day itinerary for a family of 3 traveling to London") + composeTestRule.onNodeWithTag("buttonSend").performClick() + composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithTag("gmpA2UIView").fetchSemanticsNodes().isNotEmpty() + } + + // Verify that the A2UIView container (which renders the mock JSON) exists + composeTestRule.onNodeWithTag("gmpA2UIView").assertExists() + + // Scroll the message list to see the full content + composeTestRule.onNodeWithTag("recyclerView").performScrollToIndex(1) // Pause to let the observer see the final state before the next test starts Thread.sleep(2000) diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index a6a1d2a..6e4d7e1 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -16,8 +16,6 @@ --> - - Unit, - private val onAgentAction: (actionName: String, contextJson: String) -> Unit, -) : ListAdapter(ChatMessageDiffCallback()) { - - fun updateMessages(newMessages: List) { - submitList(newMessages) - } - - private val VIEW_TYPE_TEXT = 1 - private val VIEW_TYPE_GMPA2UIVIEW = 2 - private val VIEW_TYPE_LOADING = 3 - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is ChatMessage.Text -> VIEW_TYPE_TEXT - is ChatMessage.GmpA2UIView -> VIEW_TYPE_GMPA2UIVIEW - is ChatMessage.Loading -> VIEW_TYPE_LOADING - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - VIEW_TYPE_TEXT -> { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_text, parent, false) - TextViewHolder(view) - } - VIEW_TYPE_GMPA2UIVIEW -> { - val view = - LayoutInflater.from(parent.context) - .inflate(R.layout.item_maui_gmp_a2ui_view, parent, false) - GmpA2UIViewHolder(view, onGmpA2UIViewRendered, onAgentAction) - } - VIEW_TYPE_LOADING -> { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_loading, parent, false) - LoadingViewHolder(view) - } - else -> throw IllegalArgumentException("Invalid view type") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val message = getItem(position)) { - is ChatMessage.Text -> (holder as TextViewHolder).bind(message) - is ChatMessage.GmpA2UIView -> { - (holder as GmpA2UIViewHolder).bind( - message.a2uiJsonString, - message.startTime, - position == itemCount - 1, - ) - } - is ChatMessage.Loading -> {} - } - } - - class TextViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val textView: TextView = itemView.findViewById(R.id.textViewMessage) - - fun bind(message: ChatMessage.Text) { - textView.text = message.text - val layoutParams = textView.layoutParams as ViewGroup.MarginLayoutParams - if (message.isUser) { - textView.setBackgroundResource(R.drawable.rounded_corner_user) - } else { - textView.setBackgroundResource(R.drawable.rounded_corner) - } - textView.layoutParams = layoutParams - } - } - - class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {} - - class GmpA2UIViewHolder( - itemView: View, - private val onGmpA2UIViewRendered: (position: Int, latencyMs: Long, status: String) -> Unit, - private val onAgentAction: (actionName: String, contextJson: String) -> Unit, - ) : RecyclerView.ViewHolder(itemView) { - val a2uiView: A2UIView = itemView.findViewById(R.id.gmpA2UIView) - - init { - a2uiView.onRenderComplete = { latencyMs, status -> - if (adapterPosition != RecyclerView.NO_POSITION) { - onGmpA2UIViewRendered(adapterPosition, latencyMs, status) - } - } - a2uiView.onUserAction = { actionJson -> onAgentAction("get_directions", actionJson) } - } - - fun bind(serverResponse: String, startTime: Long?, isLatestResponse: Boolean) { - a2uiView.render(serverResponse, startTime) - } - } - - companion object { - private const val TAG = "ChatAdapter" - } -} - -class ChatMessageDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldChatMessage: ChatMessage, newChatMessage: ChatMessage): Boolean { - return oldChatMessage == newChatMessage - } - - override fun areContentsTheSame( - oldChatMessage: ChatMessage, - newChatMessage: ChatMessage, - ): Boolean { - return oldChatMessage == newChatMessage - } -} diff --git a/client/android/app/src/main/java/com/example/maui/ChatMessage.kt b/client/android/app/src/main/java/com/example/maui/ChatMessage.kt index 718e7fa..5bb1716 100644 --- a/client/android/app/src/main/java/com/example/maui/ChatMessage.kt +++ b/client/android/app/src/main/java/com/example/maui/ChatMessage.kt @@ -16,10 +16,24 @@ package com.example.maui +import java.util.UUID + sealed class ChatMessage { - data class Text(val text: String, val isUser: Boolean) : ChatMessage() + abstract val id: String + + data class Text( + val text: String, + val isUser: Boolean, + override val id: String = UUID.randomUUID().toString(), + ) : ChatMessage() - data class GmpA2UIView(val a2uiJsonString: String, val startTime: Long? = null) : ChatMessage() + data class GmpA2UIView( + val a2uiJsonString: String, + val startTime: Long? = null, + override val id: String = UUID.randomUUID().toString(), + ) : ChatMessage() - object Loading : ChatMessage() + object Loading : ChatMessage() { + override val id: String = "loading" + } } diff --git a/client/android/app/src/main/java/com/example/maui/MainActivity.kt b/client/android/app/src/main/java/com/example/maui/MainActivity.kt index 1b44f26..40d5767 100644 --- a/client/android/app/src/main/java/com/example/maui/MainActivity.kt +++ b/client/android/app/src/main/java/com/example/maui/MainActivity.kt @@ -15,29 +15,76 @@ package com.example.maui import android.os.Bundle -import android.view.View -import android.widget.Button -import android.widget.EditText -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenu +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.maui.data.ChatRepository import com.example.maui.telemetry.LatencyLogger import com.example.maui.telemetry.ResourceLogger import com.example.maui.ui.ChatViewModel +import com.google.android.libraries.mapsplatform.a2ui.A2UIView +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -class MainActivity : AppCompatActivity() { +private data class A2UIViewRenderState(val messageId: String, val json: String) - private lateinit var recyclerView: RecyclerView - private lateinit var editTextMessage: EditText - private lateinit var buttonSend: Button - private lateinit var buttonPrintLog: Button - private lateinit var promptsSpinner: android.widget.Spinner - private lateinit var switchCannedServer: android.widget.Switch - private lateinit var chatAdapter: ChatAdapter +private val SAMPLE_PROMPTS = + listOf( + "Show me 5 coffee shops near South Lake Union in Seattle", + "Is the Edgewater Hotel in Seattle a good hotel?", + "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?", + "Show me 5 lunch restaurants with Salads in South Lake Union. Give me directions to the 2nd one (starting from the Google South Lake Union WLK building)", + "Give me a 3 day itinerary for a family of 3 traveling to London", + ) + +class MainActivity : ComponentActivity() { private lateinit var latencyLogger: LatencyLogger private lateinit var viewModel: ChatViewModel @@ -56,110 +103,325 @@ class MainActivity : AppCompatActivity() { BuildConfig.MAPS_API_KEY ) - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - setContentView(R.layout.activity_main) - - recyclerView = findViewById(R.id.recyclerView) - editTextMessage = findViewById(R.id.editTextMessage) - buttonSend = findViewById(R.id.buttonSend) - buttonPrintLog = findViewById(R.id.buttonPrintLog) - promptsSpinner = findViewById(R.id.promptsSpinner) - switchCannedServer = findViewById(R.id.switchCannedServer) - - val examplePrompts = - listOf( - "Select a frequently asked question...", - "Show me 5 coffee shops near South Lake Union in Seattle", - "Is the Edgewater Hotel in Seattle a good hotel?", - "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?", - "Show me 5 lunch restaurants with Salads in South Lake Union. Give me directions to the 2nd one (starting from the Google South Lake Union WLK building)", - "Give me a 3 day itinerary for a family of 3 traveling to London", - ) + setContent { MaterialTheme { ChatScreen(viewModel, latencyLogger) } } + } - val adapter = - android.widget.ArrayAdapter( - this, - android.R.layout.simple_spinner_dropdown_item, - examplePrompts, - ) - promptsSpinner.adapter = adapter - - promptsSpinner.onItemSelectedListener = - object : android.widget.AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: android.widget.AdapterView<*>?, - view: View?, - position: Int, - id: Long, - ) { - if (position > 0) { - editTextMessage.setText(examplePrompts[position]) - } + companion object { + private const val TAG = "MainActivity" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(viewModel: ChatViewModel, latencyLogger: LatencyLogger) { + val messages by viewModel.uiState.collectAsStateWithLifecycle() + var inputText by remember { mutableStateOf("") } + var selectedAgentType by remember { mutableStateOf(AgentType.LITE) } + var bypassCanned by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + MessageList( + messages = messages, + modifier = Modifier.weight(1f), + latencyLogger = latencyLogger, + viewModel = viewModel, + ) + + ControlPanel( + onPromptSelected = { inputText = it }, + bypassCanned = bypassCanned, + onBypassCannedChange = { bypassCanned = it }, + ) + + AgentSelectionColumn( + selectedAgentType = selectedAgentType, + onAgentTypeChange = { selectedAgentType = it }, + ) + + InputBar( + text = inputText, + onTextChange = { inputText = it }, + onSend = { + if (inputText.isNotBlank()) { + viewModel.sendMessage(inputText, selectedAgentType, bypassCanned) + inputText = "" } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControlPanel( + onPromptSelected: (String) -> Unit, + bypassCanned: Boolean, + onBypassCannedChange: (Boolean) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val defaultPrompt = stringResource(R.string.most_asked_questions) + var selectedPrompt by remember { mutableStateOf(defaultPrompt) } + val examplePrompts = remember(defaultPrompt) { listOf(defaultPrompt) + SAMPLE_PROMPTS } - override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.weight(1f), + ) { + TextField( + value = selectedPrompt, + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = + Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + examplePrompts.forEachIndexed { index, prompt -> + DropdownMenuItem( + text = { Text(text = prompt, color = MaterialTheme.colorScheme.onSurface) }, + onClick = { + selectedPrompt = prompt + expanded = false + if (index > 0) { + onPromptSelected(prompt) + } + }, + ) + } } + } - buttonPrintLog.visibility = View.GONE - - chatAdapter = - ChatAdapter( - onGmpA2UIViewRendered = { position, latencyMs, status -> - lifecycleScope.launch { - latencyLogger.logLatency("A2UI", latencyMs, status) - - // Give the RecyclerView a brief moment to finish laying out the A2UIView - // with its newly rendered, dynamic height before scrolling. - // This prevents inaccurate scrolling offsets. - kotlinx.coroutines.delay(100) - val layoutManager = - recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager - val scrollPosition = if (position > 0) position - 1 else position - layoutManager?.scrollToPositionWithOffset(scrollPosition, 0) - } - }, - onAgentAction = { actionName, contextJson -> - viewModel.handleAgentAction(actionName, contextJson) - }, + Spacer(modifier = Modifier.width(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.label_server), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) - recyclerView.setItemViewCacheSize(10) - recyclerView.adapter = chatAdapter - - buttonSend.setOnClickListener { - val messageText = editTextMessage.text.toString().trim() - if (messageText.isNotEmpty()) { - val radioAgentVertex = findViewById(R.id.radioAgentVertex) - val radioAgentTemplate = findViewById(R.id.radioAgentTemplate) - - val agentType = - if (radioAgentVertex.isChecked) { - com.example.maui.AgentType.VERTEX - } else if (radioAgentTemplate.isChecked) { - com.example.maui.AgentType.TEMPLATE - } else { - com.example.maui.AgentType.LITE - } + Spacer(modifier = Modifier.width(4.dp)) + Switch( + checked = bypassCanned, + onCheckedChange = onBypassCannedChange, + modifier = Modifier.scale(0.8f).testTag("switchCannedServer"), + ) + } + } +} - viewModel.sendMessage(messageText, agentType, switchCannedServer.isChecked) - editTextMessage.text.clear() - } +@Composable +fun AgentSelectionColumn(selectedAgentType: AgentType, onAgentTypeChange: (AgentType) -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedAgentType == AgentType.LITE, + onClick = { onAgentTypeChange(AgentType.LITE) }, + colors = + RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = Modifier.scale(0.8f).testTag("radioAgentLite"), + ) + Text( + stringResource(R.string.agent_grounding_lite), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedAgentType == AgentType.VERTEX, + onClick = { onAgentTypeChange(AgentType.VERTEX) }, + colors = + RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = Modifier.scale(0.8f).testTag("radioAgentVertex"), + ) + Text( + stringResource(R.string.agent_grounding_vertex), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedAgentType == AgentType.TEMPLATE, + onClick = { onAgentTypeChange(AgentType.TEMPLATE) }, + colors = + RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = Modifier.scale(0.8f).testTag("radioAgentTemplate"), + ) + Text( + stringResource(R.string.agent_template), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) } + } +} - lifecycleScope.launch { - viewModel.uiState.collect { messages -> - chatAdapter.updateMessages(messages) - scrollToLastMessage(messages.size) - } +@Composable +fun MessageList( + messages: List, + modifier: Modifier = Modifier, + latencyLogger: LatencyLogger, + viewModel: ChatViewModel, +) { + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.size - 1) } } - private fun scrollToLastMessage(size: Int) { - if (size > 0 && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { - recyclerView.post { recyclerView.scrollToPosition(size - 1) } + LazyColumn( + state = listState, + modifier = modifier.padding(horizontal = 8.dp).testTag("recyclerView"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = messages, key = { it.id }) { message -> + when (message) { + is ChatMessage.Text -> { + val bubbleColor = + if (message.isUser) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val textColor = + if (message.isUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (message.isUser) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Text( + text = message.text, + color = textColor, + modifier = + Modifier.background(color = bubbleColor, shape = RoundedCornerShape(8.dp)) + .padding(12.dp), + ) + } + } + is ChatMessage.Loading -> { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + is ChatMessage.GmpA2UIView -> { + AndroidView( + factory = { context -> + A2UIView(context).apply { + layoutParams = + android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, + ) + } + }, + update = { view -> + view.onRenderComplete = { latencyMs, status -> + coroutineScope.launch { + latencyLogger.logLatency("A2UI", latencyMs, status) + delay(100) + if (!listState.isScrollInProgress) { + val currentIdx = messages.indexOfFirst { it.id == message.id } + if (currentIdx >= 0) { + val scrollTarget = if (currentIdx > 0) currentIdx - 1 else currentIdx + listState.animateScrollToItem(scrollTarget) + } + } + } + } + + view.onUserAction = { actionJson -> + viewModel.handleAgentAction("get_directions", actionJson) + } + + val lastState = view.tag as? A2UIViewRenderState + if (lastState == null || lastState.messageId != message.id) { + view.render(message.a2uiJsonString, message.startTime) + view.tag = A2UIViewRenderState(message.id, message.a2uiJsonString) + } else if (lastState.json != message.a2uiJsonString) { + view.updateA2uiJson(message.a2uiJsonString) + view.tag = A2UIViewRenderState(message.id, message.a2uiJsonString) + } + }, + modifier = Modifier.fillMaxWidth().testTag("gmpA2UIView"), + ) + } + } } } +} - companion object { - private const val TAG = "MainActivity" +@Composable +fun InputBar(text: String, onTextChange: (String) -> Unit, onSend: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f).testTag("editTextMessage"), + placeholder = { + Text( + stringResource(R.string.hint_type_message), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = onSend, + shape = RectangleShape, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.testTag("buttonSend"), + ) { + Text(stringResource(R.string.button_send)) + } } } diff --git a/client/android/app/src/main/java/com/example/maui/data/ChatRepository.kt b/client/android/app/src/main/java/com/example/maui/data/ChatRepository.kt index c372a6c..a8bafa9 100644 --- a/client/android/app/src/main/java/com/example/maui/data/ChatRepository.kt +++ b/client/android/app/src/main/java/com/example/maui/data/ChatRepository.kt @@ -19,16 +19,22 @@ package com.example.maui.data import android.content.Context import android.util.Log import com.example.maui.BuildConfig +import com.google.android.libraries.mapsplatform.a2ui.A2AResponseParser +import com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent import java.io.IOException import java.util.UUID import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -67,14 +73,15 @@ class ChatRepository(private val context: Context) { val baseUrl: String get() = - when (activeServer) { - ServerType.DEMO -> BuildConfig.GATEWAY_URL - ServerType.VANILLA -> - when (deviceType) { - DeviceType.PHYSICAL -> "http://127.0.0.1:10002" - DeviceType.EMULATOR -> "http://10.0.2.2:10002" - } - } + baseUrlOverride + ?: when (activeServer) { + ServerType.DEMO -> BuildConfig.GATEWAY_URL + ServerType.VANILLA -> + when (deviceType) { + DeviceType.PHYSICAL -> "http://127.0.0.1:10002" + DeviceType.EMULATOR -> "http://10.0.2.2:10002" + } + } val appName: String get() = @@ -87,7 +94,7 @@ class ChatRepository(private val context: Context) { private val contextId = UUID.randomUUID().toString() suspend fun discoverProtocol(): Boolean = - kotlinx.coroutines.withContext(Dispatchers.IO) { + withContext(Dispatchers.IO) { discoveryMutex.withLock { if (hasDiscoveredProtocol) { return@withContext useSseProtocol ?: false @@ -140,16 +147,13 @@ class ChatRepository(private val context: Context) { it.readText() } val mapObj = JSONObject(mappingJson) - for (key in mapObj.keys()) { - if (key == text) { - val value = mapObj.getString(key) - val correctValue = - if (value.startsWith("prompt_")) "canned_responses/$value" - else value.replace("canned_prompts", "canned_responses") - val jsonString = - applicationContext.assets.open(correctValue).bufferedReader().use { it.readText() } - return JSONObject(jsonString) - } + if (mapObj.has(text)) { + val filename = mapObj.getString(text) + val jsonString = + applicationContext.assets.open("canned_responses/$filename").bufferedReader().use { + it.readText() + } + return JSONObject(jsonString) } } catch (e: Exception) { Log.e(TAG, "Error loading mapping.json or canned response", e) @@ -157,77 +161,49 @@ class ChatRepository(private val context: Context) { return null } - private fun buildRequest(useSse: Boolean, partsArray: JSONArray): Request { + private fun buildRequest(partsArray: JSONArray): Request { val requestBuilder = Request.Builder().addHeader("Content-Type", "application/json") if (activeServer == ServerType.DEMO) { requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") } - if (useSse) { - val json = - JSONObject().apply { - put("appName", appName) - put("userId", "user") - put("sessionId", activeSessionId ?: "") - put( - "newMessage", - JSONObject().apply { - put("role", "user") - put("parts", partsArray) - }, - ) + val json = + JSONObject().apply { + put("jsonrpc", JSON_RPC_VERSION) + put("method", "message/stream") + put("id", 1) + if (activeSessionId != null) { + put("sessionId", activeSessionId) } - val body = json.toString().toRequestBody("application/json".toMediaType()) - requestBuilder.url("$baseUrl/run_sse").post(body) - } else { - val json = - JSONObject().apply { - put("jsonrpc", JSON_RPC_VERSION) - put("method", "message/send") - put("id", 1) - put( - "params", - JSONObject().apply { - put( - "message", - JSONObject().apply { - put("role", "user") - put("messageId", UUID.randomUUID().toString()) - put("contextId", contextId) - put("parts", partsArray) - }, - ) - }, - ) - } - val body = json.toString().toRequestBody("application/json".toMediaType()) - requestBuilder.url("$baseUrl/?key=$apiKey").post(body) - } + put( + "params", + JSONObject().apply { + put( + "message", + JSONObject().apply { + put("role", "user") + put("messageId", UUID.randomUUID().toString()) + put("contextId", contextId) + put("parts", partsArray) + }, + ) + }, + ) + } + val body = json.toString().toRequestBody("application/json".toMediaType()) - return requestBuilder.build() - } + val urlString = if (activeServer == ServerType.DEMO) "$baseUrl/?key=$apiKey" else baseUrl + requestBuilder.url(urlString).post(body) - private fun parseSseEvent(data: String): String { - var textDelta = "" - try { - val jsonObj = JSONObject(data) - val parts = jsonObj.optJSONArray("parts") - if (parts != null) { - for (i in 0 until parts.length()) { - val p = parts.optJSONObject(i) - if (p != null && p.has("text")) { - textDelta += p.optString("text") - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error parsing SSE data: $data", e) + if (activeServer == ServerType.VANILLA) { + requestBuilder.addHeader("x-api-key", apiKey) } - return textDelta + + return requestBuilder.build() } - fun callPythonServer(userMessage: JSONObject): Flow> = + fun callPythonServer(userMessage: JSONObject): Flow>> = flow { val textStr = userMessage.optString("text") val bypassCanned = userMessage.optBoolean("bypassCanned", false) @@ -235,11 +211,13 @@ class ChatRepository(private val context: Context) { if (cannedResponse != null) { delay(2000) // Simulate network delay - emit(Result.success(AgentResponse("", cannedResponse.toString(), isCanned = true))) + val payload = cannedResponse.optJSONObject("result") ?: cannedResponse + val parsed = A2AResponseParser.parse(payload) + emit(Result.success(parsed)) return@flow } - val useSse = discoverProtocol() + discoverProtocol() // Just to ensure session is logged if needed val partsArray = JSONArray() if (userMessage.has("text")) { partsArray.put(JSONObject().apply { put("text", userMessage.optString("text")) }) @@ -251,7 +229,7 @@ class ChatRepository(private val context: Context) { ) } - val request = buildRequest(useSse, partsArray) + val request = buildRequest(partsArray) try { client.newCall(request).execute().use { response -> @@ -260,34 +238,79 @@ class ChatRepository(private val context: Context) { return@use } - if (useSse || response.header("Content-Type")?.contains("text/event-stream") == true) { - val source = response.body?.source() ?: return@use - val globalSseAccumulator = StringBuilder() - while (!source.exhausted()) { - val line = source.readUtf8Line() - if (line != null && line.startsWith(SSE_DATA_PREFIX)) { - val data = line.removePrefix(SSE_DATA_PREFIX) - if (data.isNotEmpty() && data != SSE_DONE_MESSAGE) { - val textDelta = parseSseEvent(data) - if (textDelta.isNotEmpty()) { - globalSseAccumulator.append(textDelta) + val source = response.body?.source() ?: return@use + + val buffer = StringBuilder() + + suspend fun emitParsedData(rawJson: JSONObject) { + val payload = rawJson.optJSONObject("result") ?: rawJson + val errorObj = rawJson.opt("error") ?: payload.opt("error") + if (errorObj != null) { + val errorMessage = + (errorObj as? JSONObject)?.optString("message")?.takeIf { it.isNotEmpty() } + ?: errorObj.toString() + emit(Result.failure(Exception("Server Error: $errorMessage"))) + return + } + + val kind = payload.optString("kind") + if (kind == "task") return + + var finalPayloadToParse = payload + if (kind == "status-update") { + val statusObj = payload.optJSONObject("status") + val messageObj = statusObj?.optJSONObject("message") + if (messageObj != null) { + finalPayloadToParse = messageObj + } + } + + val parsedEvents = A2AResponseParser.parse(finalPayloadToParse) + emit(Result.success(parsedEvents)) + } + + while (!source.exhausted()) { + if (!currentCoroutineContext().isActive) { + break + } + val line = source.readUtf8Line() + + if (line != null) { + var dataString = "" + val isSseMetadataOrComment = + line.startsWith("id:") || + line.startsWith("event:") || + line.startsWith(":") || + line.startsWith("retry:") + + if (line.startsWith(SSE_DATA_PREFIX)) { + dataString = line.removePrefix(SSE_DATA_PREFIX) + if (dataString == SSE_DONE_MESSAGE) { + break + } + } else if (line.isNotEmpty() && !isSseMetadataOrComment) { + dataString = line + } + + if (dataString.isNotEmpty()) { + buffer.append(dataString).append("\n") + val currentBufferString = buffer.toString().trim() + + if (currentBufferString.startsWith("{")) { + try { + val rawJson = JSONObject(currentBufferString) + buffer.clear() + emitParsedData(rawJson) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // If JSONObject threw JSONException, keep the incomplete fragment in buffer + // to reassemble with the next line. If JSONObject succeeded, buffer is + // already cleared above. } - emit(Result.success(AgentResponse(globalSseAccumulator.toString(), data))) } } } - } else { - val responseData = response.body?.string() ?: return@use - try { - val jsonResponse = JSONObject(responseData) - val resultObj = jsonResponse.opt("result") - val finalJson = - if (resultObj is JSONObject) resultObj.toString() - else if (resultObj is String) resultObj else jsonResponse.toString() - emit(Result.success(AgentResponse("", finalJson))) - } catch (e: Exception) { - emit(Result.failure(Exception("Error parsing JSON: ${e.message}"))) - } } } } catch (e: IOException) { @@ -301,5 +324,13 @@ class ChatRepository(private val context: Context) { private const val SSE_DATA_PREFIX = "data: " private const val SSE_DONE_MESSAGE = "[DONE]" private const val JSON_RPC_VERSION = "2.0" + + /** + * Overrides the backend base URL, e.g. "https://maui-backend.google.com". + * + * Set by integration tests that drive the app against a server other than the one baked in at + * build time. `null`, the default, keeps [BuildConfig.GATEWAY_URL]. + */ + @Volatile @JvmStatic var baseUrlOverride: String? = null } } diff --git a/client/android/app/src/main/java/com/example/maui/ui/ChatViewModel.kt b/client/android/app/src/main/java/com/example/maui/ui/ChatViewModel.kt index 5a1b746..6df9e37 100644 --- a/client/android/app/src/main/java/com/example/maui/ui/ChatViewModel.kt +++ b/client/android/app/src/main/java/com/example/maui/ui/ChatViewModel.kt @@ -17,10 +17,14 @@ package com.example.maui.ui import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.example.maui.AgentType import com.example.maui.ChatMessage import com.example.maui.data.ChatRepository import com.example.maui.telemetry.ResourceLogger +import com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent +import java.util.UUID import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -39,29 +43,39 @@ class ChatViewModel( val uiState: StateFlow> = _uiState.asStateFlow() private var currentRequestJob: Job? = null - private var currentAgentTextIndex: Int? = null - private var currentAgentA2UIIndex: Int? = null + private var currentStreamingMessageId: String? = null + private var currentUpdateComponentsMsg: JSONObject? = null + private val currentUpdateDataModelMsgs = mutableListOf() + private val currentStreamingText = StringBuilder() + private var currentStreamingTextId: String? = null init { resourceLogger.startLogging(viewModelScope) } + private fun resetStreamingStateAndCancelJob() { + currentRequestJob?.cancel() + currentStreamingMessageId = null + currentUpdateComponentsMsg = null + currentUpdateDataModelMsgs.clear() + currentStreamingText.clear() + currentStreamingTextId = null + } + fun sendMessage( text: String, - agentType: com.example.maui.AgentType = com.example.maui.AgentType.LITE, + agentType: AgentType = AgentType.LITE, bypassCanned: Boolean = false, ) { - currentRequestJob?.cancel() - currentAgentTextIndex = null - currentAgentA2UIIndex = null + resetStreamingStateAndCancelJob() val serverMessageText = when (agentType) { - com.example.maui.AgentType.VERTEX -> "[GROUNDING] $text" - com.example.maui.AgentType.TEMPLATE -> "[TEMPLATE] $text" - com.example.maui.AgentType.LITE -> text + AgentType.VERTEX -> "[GROUNDING] $text" + AgentType.TEMPLATE -> "[TEMPLATE] $text" + AgentType.LITE -> text } - addMessage(ChatMessage.Text(text, true)) + addMessage(ChatMessage.Text(text = text, isUser = true)) val jsonObject = JSONObject().apply { put("text", serverMessageText) @@ -71,6 +85,7 @@ class ChatViewModel( } fun handleAgentAction(actionName: String, contextJson: String) { + resetStreamingStateAndCancelJob() val userAction = JSONObject().apply { put("name", actionName) @@ -83,31 +98,19 @@ class ChatViewModel( private fun sendPayload(jsonObject: JSONObject) { addMessage(ChatMessage.Loading) currentRequestJob = viewModelScope.launch { - repository.callPythonServer(jsonObject).collect { result -> - result - .onSuccess { agentResponse -> - removeLastLoadingMessage() - if (agentResponse.isCanned) { - processJsonResponse(JSONObject(agentResponse.a2uiJson)) - } else { - if (agentResponse.conversationalText.isNotEmpty()) { - val textUpdate = - JSONObject() - .put( - "parts", - JSONArray().put(JSONObject().put("text", agentResponse.conversationalText)), - ) - processJsonResponse(textUpdate) - } - if (agentResponse.a2uiJson.isNotEmpty()) { - processJsonResponse(JSONObject(agentResponse.a2uiJson)) - } + try { + repository.callPythonServer(jsonObject).collect { result -> + result + .onSuccess { parsedEvents -> handleParsedEvents(parsedEvents) } + .onFailure { exception -> + removeLastLoadingMessage() + addMessage( + ChatMessage.Text(text = exception.message ?: "Unknown Error", isUser = false) + ) } - } - .onFailure { exception -> - removeLastLoadingMessage() - addMessage(ChatMessage.Text(exception.message ?: "Unknown Error", false)) - } + } + } finally { + removeLastLoadingMessage() } } } @@ -126,81 +129,147 @@ class ChatViewModel( } } - private fun processJsonResponse(json: JSONObject) { - if (json.has("error")) { - val error = json.opt("error") - val errorMsg = - if (error is JSONObject) error.optString("message") - else error?.toString() ?: "Unknown error" - addMessage(ChatMessage.Text("Server Error: $errorMsg", false)) - return - } - - val parsedParts = - try { - com.google.android.libraries.mapsplatform.a2ui.A2AResponseParser.parse(json) - } catch (e: Exception) { - emptyList() - } - - var aggregatedText = StringBuilder() - var aggregatedJson = JSONArray() + private fun handleParsedEvents(events: List) { + var foundRealContent = false + var updatedTextMessage: ChatMessage.Text? = null + var updatedMapSpec: Pair? = null + val deletedMapMessageIds = mutableSetOf() + val fallbackStartTime = System.currentTimeMillis() - for (part in parsedParts) { + for (part in events) { when (part) { - is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Text -> { - if (aggregatedText.isNotEmpty()) aggregatedText.append("\n") - aggregatedText.append(part.text) + is ParsedA2AEvent.Text -> { + if (part.text.isNotEmpty()) { + foundRealContent = true + currentStreamingText.append(part.text) + val fullText = currentStreamingText.toString() + if (currentStreamingTextId == null) { + currentStreamingTextId = UUID.randomUUID().toString() + } + updatedTextMessage = + ChatMessage.Text(text = fullText, isUser = false, id = currentStreamingTextId!!) + } } - is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Data -> { - if (part.data != "[]") { - try { - val array = JSONArray(part.data) - for (j in 0 until array.length()) { - aggregatedJson.put(array.get(j)) + is ParsedA2AEvent.Data -> { + val jsonString = part.data + if (jsonString.contains(DUMMY_STATUS_TOKEN)) { + continue + } + foundRealContent = true + + try { + val incomingArray = + if (jsonString.trim().startsWith("[")) { + JSONArray(jsonString) + } else { + JSONArray().put(JSONObject(jsonString)) + } + for (i in 0 until incomingArray.length()) { + val a2uiMessage = incomingArray.optJSONObject(i) ?: continue + when { + a2uiMessage.has(KEY_DELETE_SURFACE) -> { + currentStreamingMessageId?.let { deletedMapMessageIds.add(it) } + currentStreamingMessageId = null + currentUpdateComponentsMsg = null + currentUpdateDataModelMsgs.clear() + updatedMapSpec = null + } + a2uiMessage.has(KEY_CREATE_SURFACE) -> { + // Omit createSurface so core-shell.ts auto-injects it at index 0 + // only when the surface does not exist yet. + } + a2uiMessage.has(KEY_UPDATE_COMPONENTS) -> { + val componentsArray = + a2uiMessage.optJSONObject(KEY_UPDATE_COMPONENTS)?.optJSONArray(KEY_COMPONENTS) + if (componentsArray != null) { + for (j in 0 until componentsArray.length()) { + val componentJson = componentsArray.optJSONObject(j) ?: continue + if (componentJson.optString(KEY_COMPONENT) == COMPONENT_GOOGLE_MAP) { + if (componentJson.opt(KEY_MARKERS) is JSONObject) { + componentJson.put(KEY_MARKERS, JSONArray()) + } + if (componentJson.opt(KEY_ROUTES) is JSONObject) { + componentJson.put(KEY_ROUTES, JSONArray()) + } + } + } + } + currentUpdateComponentsMsg = a2uiMessage + } + a2uiMessage.has(KEY_UPDATE_DATA_MODEL) -> { + currentUpdateDataModelMsgs.add(a2uiMessage) + } } - } catch (e: Exception) {} + } + } catch (e: Exception) { + // Fallback if jsonString is non-standard + } + + val componentsMsg = currentUpdateComponentsMsg ?: continue + val outArray = JSONArray() + outArray.put(componentsMsg) + for (dataModelMessage in currentUpdateDataModelMsgs) { + outArray.put(dataModelMessage) + } + val accumulatedJson = outArray.toString() + + if (currentStreamingMessageId == null) { + currentStreamingMessageId = UUID.randomUUID().toString() } + updatedMapSpec = Pair(currentStreamingMessageId!!, accumulatedJson) } } } - val finalConversationalText = aggregatedText.toString() - val finalA2uiJson = if (aggregatedJson.length() > 0) aggregatedJson.toString() else "" + _uiState.update { currentList -> + val mutableList = currentList.toMutableList() - if (finalConversationalText.isNotEmpty() || finalA2uiJson.isNotEmpty()) { - _uiState.update { currentList -> - val mutableList = currentList.toMutableList() - if (finalConversationalText.isNotEmpty()) { - currentAgentTextIndex?.let { idx -> - if (idx < mutableList.size) { - mutableList[idx] = ChatMessage.Text(finalConversationalText, false) - } - } - ?: run { - mutableList.add(ChatMessage.Text(finalConversationalText, false)) - currentAgentTextIndex = mutableList.size - 1 - } + if ( + foundRealContent && mutableList.isNotEmpty() && mutableList.last() is ChatMessage.Loading + ) { + mutableList.removeAt(mutableList.size - 1) + } + + if (deletedMapMessageIds.isNotEmpty()) { + mutableList.removeAll { (it as? ChatMessage.GmpA2UIView)?.id in deletedMapMessageIds } + } + + if (updatedTextMessage != null) { + val targetTextIndex = mutableList.indexOfFirst { + it is ChatMessage.Text && it.id == updatedTextMessage.id } - if (finalA2uiJson.isNotEmpty() && finalA2uiJson != "[]") { - currentAgentA2UIIndex?.let { idx -> - if (idx < mutableList.size) { - val oldMsg = mutableList[idx] as? ChatMessage.GmpA2UIView - mutableList[idx] = - ChatMessage.GmpA2UIView( - finalA2uiJson, - oldMsg?.startTime ?: System.currentTimeMillis(), - ) - } - } - ?: run { - val gmpViewStartTime = System.currentTimeMillis() - mutableList.add(ChatMessage.GmpA2UIView(finalA2uiJson, gmpViewStartTime)) - currentAgentA2UIIndex = mutableList.size - 1 - } + if (targetTextIndex != -1) { + mutableList[targetTextIndex] = updatedTextMessage + } else { + mutableList.add(updatedTextMessage) } - mutableList.toList() } + + if (updatedMapSpec != null) { + val (messageId, accumulatedJson) = updatedMapSpec + val targetIndex = mutableList.indexOfFirst { + (it as? ChatMessage.GmpA2UIView)?.id == messageId + } + val existingStartTime = + if (targetIndex != -1) { + (mutableList[targetIndex] as? ChatMessage.GmpA2UIView)?.startTime + } else null + + val newMessage = + ChatMessage.GmpA2UIView( + a2uiJsonString = accumulatedJson, + startTime = existingStartTime ?: fallbackStartTime, + id = messageId, + ) + + if (targetIndex != -1) { + mutableList[targetIndex] = newMessage + } else { + mutableList.add(newMessage) + } + } + + mutableList.toList() } } @@ -210,11 +279,22 @@ class ChatViewModel( } companion object { + private const val DUMMY_STATUS_TOKEN = "dummy_status" + private const val KEY_DELETE_SURFACE = "deleteSurface" + private const val KEY_CREATE_SURFACE = "createSurface" + private const val KEY_UPDATE_COMPONENTS = "updateComponents" + private const val KEY_UPDATE_DATA_MODEL = "updateDataModel" + private const val KEY_COMPONENTS = "components" + private const val KEY_COMPONENT = "component" + private const val COMPONENT_GOOGLE_MAP = "GoogleMap" + private const val KEY_MARKERS = "markers" + private const val KEY_ROUTES = "routes" + fun provideFactory( repository: ChatRepository, resourceLogger: ResourceLogger, - ): androidx.lifecycle.ViewModelProvider.Factory = - object : androidx.lifecycle.ViewModelProvider.Factory { + ): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { return ChatViewModel(repository, resourceLogger) as T diff --git a/client/android/app/src/main/res/layout/activity_main.xml b/client/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 7891a55..0000000 --- a/client/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -