diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c1aaaf9..9210a5e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -47,4 +47,4 @@ Please be sure to include as much information as possible: Following these steps will guarantee the quickest resolution possible. -Thanks! +Thanks! \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 7a19c42..f602f16 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -32,4 +32,4 @@ A clear and concise description of what you want to happen. A clear and concise description of any alternative solutions or features you've considered. **Additional context** -Add any other context or screenshots about the feature request here. +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/support_request.md b/.github/ISSUE_TEMPLATE/support_request.md index 997deef..6f8542b 100644 --- a/.github/ISSUE_TEMPLATE/support_request.md +++ b/.github/ISSUE_TEMPLATE/support_request.md @@ -18,4 +18,4 @@ If your bug or feature request is not related to this particular library, please Check for answers on StackOverflow with the [google-maps](http://stackoverflow.com/questions/tagged/google-maps) tag. ---- +--- \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 226fe60..f266a80 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,20 @@ updates: directory: "/client/web/react" schedule: interval: "weekly" + open-pull-requests-limit: 0 + cooldown: + default-days: 7 - package-ecosystem: "pip" directory: "/agent/python" schedule: interval: "weekly" + open-pull-requests-limit: 0 + cooldown: + default-days: 7 - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" + open-pull-requests-limit: 0 + cooldown: + default-days: 7 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 3aabe70..8c4f481 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -24,5 +24,3 @@ jobs: - name: Run zizmor uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1 - with: - args: --ignore insufficient-cooldown diff --git a/agent/python/README.md b/agent/python/README.md index 33ea205..b34771f 100644 --- a/agent/python/README.md +++ b/agent/python/README.md @@ -1,107 +1,142 @@ # Agentic UI Toolkit - Python Agent -This is a sample Python agent that consumes the MAUI packages and provides a backend for the A2UI chat interface. +This is a sample Python agent that consumes the MAUI packages and provides a +backend for the A2UI chat interface. ## Prerequisites **Source code:** -* Download/clone the Agentic UI Toolkit source code from [GitHub](https://github.com/googlemaps/a2ui) -**Environment variables:** -This example requires the following environment variables to be set: +* Download/clone the Agentic UI Toolkit source code from + [GitHub](https://github.com/googlemaps/a2ui) + +**Environment variables:** This example requires the following environment +variables to be set: + * `GEMINI_API_KEY`: Your Gemini API key. -* `GOOGLE_MAPS_API_KEY`: Your Google Maps API key (used by the agent for location-based queries). +* `GOOGLE_MAPS_API_KEY`: Your Google Maps API key (used by the agent for + location-based queries). **Tools:** -* `uv`: Python package manager and runner. Install from https://docs.astral.sh/uv/ +* `uv`: Python package manager and runner. Install from + https://docs.astral.sh/uv/ ## To run this sample project 1. Open this directory in a terminal. 2. Set the path to the MAUI package in [pyproject.toml](pyproject.toml). - - You can either do this manually by replacing the `$MAUI_PATH` placeholder in [pyproject.toml](pyproject.toml) - with the path to the MAUI package, or by running the [setup.sh](setup.sh) script: + + You can either do this manually by replacing the `$MAUI_PATH` placeholder in + [pyproject.toml](pyproject.toml) with the path to the MAUI package, or by + running the [setup.sh](setup.sh) script: ```bash chmod +x setup.sh ./setup.sh ``` + 3. Run the following command to start the server: ```bash uv run . ``` - This will automatically resolve dependencies, install them in a local virtual environment, and start the A2A server on port 10002. + This will automatically resolve dependencies, install them in a local + virtual environment, and start the A2A server on port 10002. -To run the frontend, follow the instructions in [../../client/web/react/README.md](../../client/web/react/README.md) +To run the frontend, follow the instructions in +[../../client/web/react/README.md](../../client/web/react/README.md) ## Google API Keys ### Google Maps API Key -Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To create a Google Maps API Key, follow the instructions in the [Google Maps Platform documentation](https://developers.google.com/maps/documentation/javascript/get-api-key). +Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To +create a Google Maps API Key, follow the instructions in the +[Google Maps Platform documentation](https://developers.google.com/maps/documentation/javascript/get-api-key). -Your API Key must have the following APIs enabled in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials): +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 +* Geocoding API +* Maps JavaScript API +* Places UI Kit +* Routes API To use Grounding Lite MCP, you must also enable: -* Maps Grounding Lite API +* Maps Grounding Lite API -To support the use of Grounding Lite within the Python ADK backend, this API Key must be exported or contained within a `.env` file as `GOOGLE_MAPS_API_KEY`. +To support the use of Grounding Lite within the Python ADK backend, this API Key +must be exported or contained within a `.env` file as `GOOGLE_MAPS_API_KEY`. **Loading the Google Maps JavaScript API** -Your API Key must also be included when loading the Google Maps JavaScript API code. See the [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/load-maps-js-api) for instructions on how to load the API, including configuring the API Key. +Your API Key must also be included when loading the Google Maps JavaScript API +code. See the +[Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/load-maps-js-api) +for instructions on how to load the API, including configuring the API Key. -Agentic UI Toolkit requires features available in the Alpha channel. You must use `v=alpha` when loading the Maps JavaScript API. Learn more about versions in the [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/versions). +Agentic UI Toolkit requires features available in the Alpha channel. You must +use `v=alpha` when loading the Maps JavaScript API. Learn more about versions in +the +[Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/versions). -Use of Agentic UI Toolkit requires several [Maps JavaScript API libraries](https://developers.google.com/maps/documentation/javascript/libraries). When loading the Google Maps JavaScript API, you must include the following libraries: +Use of Agentic UI Toolkit requires several +[Maps JavaScript API libraries](https://developers.google.com/maps/documentation/javascript/libraries). +When loading the Google Maps JavaScript API, you must include the following +libraries: -* maps -* maps3d -* marker -* places -* routes +* maps +* maps3d +* marker +* places +* routes ### Gemini API Key -*Note: This API is variously referred to in Google Cloud as the* Gemini API *and the* Generative Language API. +*Note: This API is variously referred to in Google Cloud as the* Gemini API *and +the* Generative Language API. -If you are using Gemini as your LLM, you will also need a Google Cloud API Key with the *Generative Language API* enabled. In order to enable this API for your API Key, the *Gemini API* must be enabled for your Google Cloud project. You can enable this API in the [API Library](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com). +If you are using Gemini as your LLM, you will also need a Google Cloud API Key +with the *Generative Language API* enabled. In order to enable this API for your +API Key, the *Gemini API* must be enabled for your Google Cloud project. You can +enable this API in the +[API Library](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com). -To create a new Google Cloud API Key, follow the instructions here in the [Google Cloud docs](https://docs.cloud.google.com/docs/authentication/api-keys#create). +To create a new Google Cloud API Key, follow the instructions here in the +[Google Cloud docs](https://docs.cloud.google.com/docs/authentication/api-keys#create). This key must be exported or contained within a `.env` file as `GEMINI_API_KEY` ## Accessing Google Maps grounding data -Your agent can access Google Maps grounding data in two ways, depending on your project setup and needs: +Your agent can access Google Maps grounding data in two ways, depending on your +project setup and needs: -1. [Grounding Lite MCP](https://developers.google.com/maps/ai/grounding-lite) -2. [Grounding with Google Maps](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps) +1. [Grounding Lite MCP](https://developers.google.com/maps/ai/grounding-lite) +2. [Grounding with Google Maps](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps) ### Grounding Lite MCP -To use Grounding Lite MCP, you must first enable the Maps Grounding Lite API and create or update an API Key to support the required APIs following the [documentation](https://developers.google.com/maps/ai/grounding-lite#configure_llms_to_use_the_mcp_server). +To use Grounding Lite MCP, you must first enable the Maps Grounding Lite API and +create or update an API Key to support the required APIs following the +[documentation](https://developers.google.com/maps/ai/grounding-lite#configure_llms_to_use_the_mcp_server). ### Grounding with Google Maps -To use Grounding with Google Maps, there are additional steps you must take to configure your environment: +To use Grounding with Google Maps, there are additional steps you must take to +configure your environment: + +1. Ensure you have the latest version of the genai python package. -1. Ensure you have the latest version of the genai python package. ```bash pip install --upgrade google-genai ``` -2. Configure additional environment variables to connect to your project. +1. Configure additional environment variables to connect to your project. + ```bash ## Replace the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` values ## with appropriate values for your project. @@ -110,9 +145,12 @@ export GOOGLE_CLOUD_LOCATION=global export GOOGLE_GENAI_USE_VERTEXAI=True ``` -3. Ensure you are authenticated to Google Cloud. +1. Ensure you are authenticated to Google Cloud. + ```bash gcloud auth application-default login ``` -See the [documentation](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps#googlegenaisdk_tools_google_maps_with_txt-python_genai_sdk) for more information. +See the +[documentation](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps#googlegenaisdk_tools_google_maps_with_txt-python_genai_sdk) +for more information. diff --git a/agent/python/__main__.py b/agent/python/__main__.py index 8441598..9c49718 100644 --- a/agent/python/__main__.py +++ b/agent/python/__main__.py @@ -15,20 +15,21 @@ import logging import os -import click from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCapabilities, AgentCard, AgentSkill -from agent import MAUIAgent -from agent_executor import MAUIAgentExecutor -from agent_with_grounding import MAUIAgentWithGrounding -from dotenv import load_dotenv +import click +import dotenv from starlette.middleware.cors import CORSMiddleware -from starlette.staticfiles import StaticFiles from starlette.responses import RedirectResponse +from starlette.staticfiles import StaticFiles + +from python_agent.agent import MAUIAgent +from python_agent.agent_with_grounding import MAUIAgentWithGrounding +from agent_executor import MAUIAgentExecutor -load_dotenv() +dotenv.load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -43,48 +44,52 @@ class MissingAPIKeyError(Exception): @click.option("--host", default="0.0.0.0") @click.option("--port", default=10002) def main(serverurl, host, port): - try: - # Check for API key only if Vertex AI is not configured - if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE": - if not os.getenv("GEMINI_API_KEY"): - raise MissingAPIKeyError( - "GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE." - ) + try: + # Check for API key only if Vertex AI is not configured + if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE": + if not os.getenv("GEMINI_API_KEY"): + raise MissingAPIKeyError( + "GEMINI_API_KEY environment variable not set and" + " GOOGLE_GENAI_USE_VERTEXAI is not TRUE." + ) - base_url = f"http://{host}:{port}" + base_url = f"http://{host}:{port}" - if (serverurl != ""): - base_url = serverurl + if serverurl != "": + base_url = serverurl - ui_agent = MAUIAgent(base_url=base_url) - grounding_agent = MAUIAgentWithGrounding(base_url=base_url) + ui_agent = MAUIAgent(base_url=base_url) + grounding_agent = MAUIAgentWithGrounding(base_url=base_url) - agent_executor = MAUIAgentExecutor(default_agent=ui_agent, grounding_agent=grounding_agent) + agent_executor = MAUIAgentExecutor( + default_agent=ui_agent, grounding_agent=grounding_agent + ) - request_handler = DefaultRequestHandler( - agent_executor=agent_executor, - task_store=InMemoryTaskStore(), - ) - server = A2AStarletteApplication( - agent_card=ui_agent.agent_card, http_handler=request_handler - ) - import uvicorn + request_handler = DefaultRequestHandler( + agent_executor=agent_executor, + task_store=InMemoryTaskStore(), + ) + server = A2AStarletteApplication( + agent_card=ui_agent.agent_card, http_handler=request_handler + ) + import uvicorn - app = server.build() + app = server.build() - app.add_middleware( - CORSMiddleware, - allow_origin_regex=r"http://localhost:\d+", - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) + app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"http://localhost:\d+", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + logger.info(f"Starting A2A server on {host}:{port}") + uvicorn.run(app, host=host, port=port) + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) - logger.info(f"Starting A2A server on {host}:{port}") - uvicorn.run(app, host=host, port=port) - except Exception as e: - logger.error(f"An error occurred during server startup: {e}") - exit(1) if __name__ == "__main__": - main() + main() diff --git a/agent/python/agent_executor.py b/agent/python/agent_executor.py index 8b6babd..073c652 100644 --- a/agent/python/agent_executor.py +++ b/agent/python/agent_executor.py @@ -31,9 +31,10 @@ new_task, ) from a2a.utils.errors import ServerError + from a2ui.a2a.extension import try_activate_a2ui_extension -from agent import MAUIAgent -from agent_with_grounding import MAUIAgentWithGrounding +from python_agent.agent import MAUIAgent +from python_agent.agent_with_grounding import MAUIAgentWithGrounding logger = logging.getLogger(__name__) @@ -41,7 +42,9 @@ class MAUIAgentExecutor(AgentExecutor): """MAUI AgentExecutor Example.""" - def __init__(self, default_agent: MAUIAgent, grounding_agent: MAUIAgentWithGrounding): + def __init__( + self, default_agent: MAUIAgent, grounding_agent: MAUIAgentWithGrounding + ): self._default_agent = default_agent self._grounding_agent = grounding_agent @@ -75,10 +78,10 @@ async def execute( logger.info(f"Received a2ui ClientEvent: {ui_event_part}") action = ui_event_part.get("actionName") ctx = ui_event_part.get("context", {}) - + # Use switch statement to route to the appropriate action handler query = f"User submitted an event: {action} with data: {ctx}" - + else: logger.info("No a2ui UI event part found. Falling back to text input.") query = context.get_user_input() @@ -86,23 +89,35 @@ async def execute( # Interpret prefix and choose agent agent_to_use = self._default_agent if query.startswith("[GROUNDING]"): - logger.info("--- AGENT_EXECUTOR: Prefix [GROUNDING] detected. Using Grounding Agent. ---") - agent_to_use = self._grounding_agent - query = query[len("[GROUNDING]"):].strip() + logger.info( + "--- AGENT_EXECUTOR: Prefix [GROUNDING] detected. Using Grounding" + " Agent. ---" + ) + agent_to_use = self._grounding_agent + query = query[len("[GROUNDING]") :].strip() else: - logger.info("--- AGENT_EXECUTOR: No prefix detected. Using Default Agent. ---") + logger.info( + "--- AGENT_EXECUTOR: No prefix detected. Using Default Agent. ---" + ) logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---") - logger.info(f"--- Client requested extensions: {context.requested_extensions} ---") - active_ui_version = try_activate_a2ui_extension(context, agent_to_use.agent_card) + logger.info( + f"--- Client requested extensions: {context.requested_extensions} ---" + ) + active_ui_version = try_activate_a2ui_extension( + context, agent_to_use.agent_card + ) # Determine which agent to use based on whether the a2ui extension is active. if active_ui_version: - logger.info("--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---") + logger.info( + "--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---" + ) else: logger.info( - "--- AGENT_EXECUTOR: A2UI extension is not active. Using text agent. ---" + "--- AGENT_EXECUTOR: A2UI extension is not active. Using text" + " agent. ---" ) task = context.current_task @@ -112,14 +127,20 @@ async def execute( await event_queue.enqueue_event(task) updater = TaskUpdater(event_queue, task.id, task.context_id) - async for item in agent_to_use.stream(query, task.context_id, active_ui_version): + async for item in agent_to_use.stream( + query, task.context_id, active_ui_version + ): is_task_complete = item["is_task_complete"] if not is_task_complete: message = None if "parts" in item: - message = new_agent_parts_message(item["parts"], task.context_id, task.id) + message = new_agent_parts_message( + item["parts"], task.context_id, task.id + ) elif "updates" in item: - message = new_agent_text_message(item["updates"], task.context_id, task.id) + message = new_agent_text_message( + item["updates"], task.context_id, task.id + ) if message: await updater.update_status(TaskState.working, message) diff --git a/agent/python/pyproject.toml b/agent/python/pyproject.toml index 868e895..e781a71 100644 --- a/agent/python/pyproject.toml +++ b/agent/python/pyproject.toml @@ -5,7 +5,7 @@ description = "Sample agent consuming MAUI packages" requires-python = ">=3.13" dependencies = [ "maui-a2ui-python", - "google-adk[a2a,extensions]>=1.28.0", + "google-adk[a2a,extensions]>=1.28.0,<2.0.0", "a2a-sdk[http-server]>=0.3.0", "google-genai>=1.64.0", "jsonschema>=4.0.0" diff --git a/client/android/README.md b/client/android/README.md index 1fe5321..749c354 100644 --- a/client/android/README.md +++ b/client/android/README.md @@ -9,16 +9,16 @@ This directory contains the Android sample application for the Google Maps Agent ### 1. Build and Publish the A2UI SDK Locally -Before building the sample app, you must build the underlying **GoogleMapsA2UI** library (the core A2UI SDK) and publish it to your local Maven repository. +Before building the sample app, you must build the underlying **GoogleMapsA2UI** library (the core A2UI SDK) and publish it to your local Maven repository. For instructions on how to build and publish the library, please refer to the [A2UI Android README](https://github.com/googlemaps/a2ui/tree/main/client/android/README.md). ### 2. Set API Keys and Gateway URL -Add your API keys and server connection settings to the `local.properties` file in the root `android` directory (e.g., `ai-kit/a2ui-samples/client/android/local.properties`): +Add your API keys and server connection settings to the `local.properties` file in this directory: ```properties -sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk +sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk MAPS_API_KEY=your_actual_google_maps_api_key_here GATEWAY_API_KEY=your_actual_gateway_api_key_here GATEWAY_URL=your_actual_gateway_url_here @@ -27,7 +27,7 @@ GATEWAY_URL=your_actual_gateway_url_here The build system uses the `secrets-gradle-plugin` to securely inject these values into the app at runtime. * **`MAPS_API_KEY`**: Obtain a Google Maps API Key from the Google Cloud Console. -* **`GATEWAY_URL`** and **`GATEWAY_API_KEY`**: +* **`GATEWAY_URL`** and **`GATEWAY_API_KEY`**: * **For Remote Server:** If you have deployed a Remote Server to Google Cloud, set `GATEWAY_URL` to your Cloud Run or API Gateway endpoint. Optionally, set `GATEWAY_API_KEY` if your server uses API key-based authentication. * **For Local Server:** Set `GATEWAY_URL` to `http://127.0.0.1:10002` (physical device) or `http://10.0.2.2:10002` (emulator). @@ -47,16 +47,12 @@ In `app/src/main/java/com/example/maui/MainActivity.kt`, verify the flags match ### 4. Build and Run the App -1. Navigate to the Android sample app directory: - ```bash - cd ~/ai-kit/a2ui-samples/client/android - ``` -2. Build and install the app (Debug version): +1. Build and install the app (Debug version): ```bash ./gradlew :app:installDebug ``` *(For release builds, use `./gradlew :app:installRelease`)* -3. Launch the app on your emulator or connected device: +2. Launch the app on your emulator or connected device: ```bash adb shell am start -n com.example.maui/.MainActivity ``` diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index 8293d6d..3920b2e 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. diff --git a/client/android/app/src/androidTest/AndroidManifest.xml b/client/android/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..25d180b --- /dev/null +++ b/client/android/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/client/android/app/src/androidTest/java/com/example/maui/MainActivityTest.kt b/client/android/app/src/androidTest/java/com/example/maui/MainActivityTest.kt new file mode 100644 index 0000000..bae2336 --- /dev/null +++ b/client/android/app/src/androidTest/java/com/example/maui/MainActivityTest.kt @@ -0,0 +1,138 @@ +// +// 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. +// + +package com.example.maui + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.swipeUp +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) + + @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) + + // 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()) + + // Pause to let the observer see the final state before the next test starts + Thread.sleep(2000) + } + + @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) + + // 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()) + + // Pause to let the observer see the final state before the next test starts + Thread.sleep(2000) + } + + @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(), + ) + 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()) + + // Pause to let the observer see the final state before the next test starts + Thread.sleep(2000) + } + + @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(), + ) + 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()) + + // Pause to let the observer see the final state before the next test starts + Thread.sleep(2000) + } + + @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()) + + // 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 90bb004..73a10b9 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + + + diff --git a/client/android/app/src/main/assets/canned_responses/prompt_1.json b/client/android/app/src/main/assets/canned_responses/prompt_1.json index 6746538..dccc8d4 100644 --- a/client/android/app/src/main/assets/canned_responses/prompt_1.json +++ b/client/android/app/src/main/assets/canned_responses/prompt_1.json @@ -67,7 +67,7 @@ }, { "id": "place-card", - "component": "PlaceCard", + "component": "PlaceDetailsCompact", "placeId": { "path": "placeId" } @@ -112,4 +112,4 @@ } } ] -} \ No newline at end of file +} diff --git a/client/android/app/src/main/assets/canned_responses/prompt_2.json b/client/android/app/src/main/assets/canned_responses/prompt_2.json index 7f6bd48..d4b3643 100644 --- a/client/android/app/src/main/assets/canned_responses/prompt_2.json +++ b/client/android/app/src/main/assets/canned_responses/prompt_2.json @@ -35,6 +35,7 @@ { "id": "hotel-map", "component": "GoogleMap", + "mode": "satellite", "center": { "lat": 47.612359, "lng": -122.352237 @@ -50,7 +51,7 @@ }, { "id": "hotel-card", - "component": "PlaceCard", + "component": "PlaceDetailsCompact", "placeId": "places/ChIJhdZMaVIVkFQR2USwWqzU5bA" } ] @@ -65,4 +66,4 @@ } } ] -} \ No newline at end of file +} diff --git a/client/android/app/src/main/assets/canned_responses/prompt_3.json b/client/android/app/src/main/assets/canned_responses/prompt_3.json index f2bda24..2de0a98 100644 --- a/client/android/app/src/main/assets/canned_responses/prompt_3.json +++ b/client/android/app/src/main/assets/canned_responses/prompt_3.json @@ -18,7 +18,7 @@ "children": [ "intro-text", "commute-map", - "place_card_1" + "place_details_compact_1" ] }, { @@ -50,8 +50,8 @@ ] }, { - "id": "place_card_1", - "component": "PlaceCard", + "id": "place_details_compact_1", + "component": "PlaceDetailsCompact", "placeId": "places/ChIJF0JJdpgTkFQR2xjapNaR-as" } ] @@ -66,4 +66,4 @@ } } ] -} \ No newline at end of file +} diff --git a/client/android/app/src/main/assets/canned_responses/prompt_4.json b/client/android/app/src/main/assets/canned_responses/prompt_4.json index ed7e329..198b3c5 100644 --- a/client/android/app/src/main/assets/canned_responses/prompt_4.json +++ b/client/android/app/src/main/assets/canned_responses/prompt_4.json @@ -60,7 +60,7 @@ }, { "id": "restaurant-card-template", - "component": "PlaceCard", + "component": "PlaceDetailsCompact", "placeId": { "path": "placeId" } @@ -95,4 +95,4 @@ } } ] -} \ No newline at end of file +} diff --git a/client/android/app/src/main/assets/canned_responses/prompt_5.json b/client/android/app/src/main/assets/canned_responses/prompt_5.json index 5624dae..9c9cd54 100644 --- a/client/android/app/src/main/assets/canned_responses/prompt_5.json +++ b/client/android/app/src/main/assets/canned_responses/prompt_5.json @@ -151,7 +151,7 @@ }, { "id": "place-card", - "component": "PlaceCard", + "component": "PlaceDetailsCompact", "placeId": { "path": "placeId" } @@ -200,4 +200,4 @@ } } ] -} \ No newline at end of file +} diff --git a/client/android/app/src/main/java/com/example/maui/ChatAdapter.kt b/client/android/app/src/main/java/com/example/maui/ChatAdapter.kt index 63d373a..7bc87a7 100644 --- a/client/android/app/src/main/java/com/example/maui/ChatAdapter.kt +++ b/client/android/app/src/main/java/com/example/maui/ChatAdapter.kt @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. @@ -16,127 +16,122 @@ package com.example.maui -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.google.android.libraries.mapsplatform.a2ui.A2UIView class ChatAdapter( - private val messages: MutableList, - private val onGmpA2UIViewRendered: (position: Int, latencyMs: Long, status: String) -> Unit -) : RecyclerView.Adapter() { - - 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 (messages[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, parent.context as? MainActivity, onGmpA2UIViewRendered) - } - VIEW_TYPE_LOADING -> { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_loading, parent, false) - LoadingViewHolder(view) - } - else -> throw IllegalArgumentException("Invalid view type") - } + private val onGmpA2UIViewRendered: (position: Int, latencyMs: Long, status: String) -> 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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val message = messages[position]) { - is ChatMessage.Text -> (holder as TextViewHolder).bind(message) - is ChatMessage.GmpA2UIView -> { - (holder as GmpA2UIViewHolder).bind(message.a2uiJsonString, message.startTime, position == messages.size - 1) - } - is ChatMessage.Loading -> { /* No binding needed for loading state */ } - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - super.onViewRecycled(holder) - if (holder is GmpA2UIViewHolder) { - // Clearing logic if needed in the future - } + } + + 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 getItemCount(): Int = messages.size - - 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 - } + } + + 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 LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - // ProgressBar is self-animating, no binding needed + } + + 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 GmpA2UIViewHolder( - itemView: android.view.View, - mainActivity: MainActivity?, - private val onGmpA2UIViewRendered: (position: Int, latencyMs: Long, status: String) -> Unit - ) : RecyclerView.ViewHolder(itemView) { - val gmpA2UIView: com.google.android.libraries.mapsplatform.a2ui.A2UIView = itemView.findViewById(R.id.gmpA2UIView) - - init { - gmpA2UIView.onRenderComplete = { latency, status -> - - if (adapterPosition != RecyclerView.NO_POSITION) { - onGmpA2UIViewRendered(adapterPosition, latency, status) - } - } - gmpA2UIView.onUserAction = { actionJson -> - if (mainActivity != null) { - try { - val context = org.json.JSONObject(actionJson) - val userAction = org.json.JSONObject().apply { - put("name", "get_directions") - put("context", context) - } - val jsonObject = org.json.JSONObject().apply { - put("userAction", userAction) - } - mainActivity.callPythonServer(jsonObject) - } catch (e: Exception) {} - } - } - } + class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {} - fun bind(serverResponse: String, startTime: Long?, isLatestResponse: Boolean) { - gmpA2UIView.render(serverResponse, startTime) - } + 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) - fun updateA2uiJson(newJson: String) { - gmpA2UIView.updateA2uiJson(newJson) + init { + a2uiView.onRenderComplete = { latencyMs, status -> + if (adapterPosition != RecyclerView.NO_POSITION) { + onGmpA2UIViewRendered(adapterPosition, latencyMs, status) } + } + a2uiView.onUserAction = { actionJson -> onAgentAction("get_directions", actionJson) } } - companion object { - private const val TAG = "ChatAdapter" + fun bind(serverResponse: String, startTime: Long?, isLatestResponse: Boolean) { + a2uiView.render(serverResponse, startTime) } -} \ No newline at end of file + } + + 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 6002d23..718e7fa 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 @@ -1,5 +1,5 @@ // -// Copyright 2026 Google Inc. +// 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. @@ -17,7 +17,9 @@ package com.example.maui sealed class ChatMessage { - data class Text(val text: String, val isUser: Boolean) : ChatMessage() - data class GmpA2UIView(val a2uiJsonString: String, val startTime: Long? = null) : ChatMessage() - object Loading : ChatMessage() + data class Text(val text: String, val isUser: Boolean) : ChatMessage() + + data class GmpA2UIView(val a2uiJsonString: String, val startTime: Long? = null) : ChatMessage() + + object Loading : ChatMessage() } 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 8ac4584..be93eeb 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 @@ -14,704 +14,146 @@ package com.example.maui -import android.app.ActivityManager -import android.content.Context import android.os.Bundle -import android.os.Process -import android.util.Log import android.view.View import android.widget.Button import android.widget.EditText -import android.widget.HorizontalScrollView -import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive +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 kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray -import org.json.JSONObject -import java.io.BufferedReader -import java.io.File -import java.io.FileReader -import java.io.FileWriter -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.UUID -import java.util.concurrent.TimeUnit -import androidx.tracing.Trace -import java.util.concurrent.atomic.AtomicInteger - -val traceCookie = AtomicInteger(0) class MainActivity : AppCompatActivity() { - 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 chatAdapter: ChatAdapter - private val messages = mutableListOf() - private val client = OkHttpClient.Builder() - .connectTimeout(300, TimeUnit.SECONDS) - .readTimeout(300, TimeUnit.SECONDS) - .writeTimeout(300, TimeUnit.SECONDS) - .build() - - enum class ServerType { - DEMO, VANILLA - } - - enum class DeviceType { - PHYSICAL, EMULATOR - } - - // --- CONFIGURATION --- - private val activeServer = ServerType.DEMO - private val deviceType = DeviceType.EMULATOR - // --------------------- - - private 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" - } - } - - private val appName: String - get() = when (activeServer) { - ServerType.DEMO -> "hello_world_agent" - ServerType.VANILLA -> "my_agent" - } - - private var activeSessionId: String? = null - private var useSseProtocol = false - private var hasDiscoveredProtocol = false - - private var currentActiveCall: okhttp3.Call? = null - - private var currentAgentTextIndex: Int? = null - private var currentAgentA2UIIndex: Int? = null - - private val contextId = java.util.UUID.randomUUID().toString() - private val latencyLogFile = "latency_log.csv" - private val resourceLogFile = "resource_log.csv" - - private val SHOW_PRINT_LOG_BUTTON = false - - private var lastCpuTime: Long = 0 - private val cpuJiffyToMs = 10L - private val loggingIntervalMs = 1000L - - private val resourceLoggingScope = CoroutineScope(Dispatchers.IO) - private var resourceLoggingJob: Job? = null - private val numberOfCores = Runtime.getRuntime().availableProcessors() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - com.google.android.libraries.mapsplatform.a2ui.A2UIServices.provideAPIKey(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) - - 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" + 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 lateinit var latencyLogger: LatencyLogger + private lateinit var viewModel: ChatViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + latencyLogger = LatencyLogger(this) + val resourceLogger = ResourceLogger(this) + val chatRepository = ChatRepository(this) + + viewModel = + ViewModelProvider(this, ChatViewModel.provideFactory(chatRepository, resourceLogger))[ + ChatViewModel::class.java] + + com.google.android.libraries.mapsplatform.a2ui.A2UIServices.provideAPIKey( + 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", + ) + + 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]) + } + } + + override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} + } + + 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) + }, + ) + recyclerView.setItemViewCacheSize(10) + recyclerView.adapter = chatAdapter + + buttonSend.setOnClickListener { + val messageText = editTextMessage.text.toString().trim() + if (messageText.isNotEmpty()) { + val radioGroundingVertex = + findViewById(R.id.radioGroundingVertex) + viewModel.sendMessage( + messageText, + radioGroundingVertex.isChecked, + switchCannedServer.isChecked, ) - - 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]) - } - } - override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} - } - - if (SHOW_PRINT_LOG_BUTTON) { - buttonPrintLog.visibility = View.VISIBLE - } else { - buttonPrintLog.visibility = View.GONE - } - - chatAdapter = ChatAdapter(messages) { position, latencyMs, status -> - logLatency("A2UI", latencyMs, status) - - // Auto-scroll to show the whole conversation (Question + Answer) - // We scroll to position - 1 to ensure the user's question stays visible at the top - lifecycleScope.launch { - delay(100) - if (position == messages.size - 1 || position == messages.size - 2) { - val scrollPosition = if (position > 0) position - 1 else position - val layoutManager = recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager - layoutManager?.scrollToPositionWithOffset(scrollPosition, 0) - } - } - } - recyclerView.setItemViewCacheSize(MAX_ITEM_VIEW_CACHE_SIZE) - recyclerView.adapter = chatAdapter - - buttonSend.setOnClickListener { - val messageText = editTextMessage.text.toString().trim() - if (messageText.isNotEmpty()) { - currentActiveCall?.cancel() - currentActiveCall = null - currentAgentTextIndex = null - currentAgentA2UIIndex = null - var serverMessageText = messageText - val radioGroundingVertex = findViewById(R.id.radioGroundingVertex) - if (radioGroundingVertex.isChecked) { - serverMessageText = "[GROUNDING] $messageText" - } - - addMessage(ChatMessage.Text(messageText, true)) - editTextMessage.text.clear() - val jsonObject = JSONObject() - jsonObject.put("text", serverMessageText) - callPythonServer(jsonObject) - } - } - - buttonPrintLog.setOnClickListener { - printResourceLogToLogcat() - printLatencyLogToLogcat() - } - - startResourceLogging() - } - - private fun startResourceLogging() { - resourceLoggingJob?.cancel() - resourceLoggingJob = resourceLoggingScope.launch { - while (isActive) { - logResourceUsage() - delay(loggingIntervalMs) - } - } - } - - private fun logResourceUsage() { - try { - val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) - val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val pid = Process.myPid() - val memoryInfo = activityManager.getProcessMemoryInfo(intArrayOf(pid)) - val debugMemoryInfo = memoryInfo[0] - val totalPss = debugMemoryInfo.totalPss - val dalvikPss = debugMemoryInfo.dalvikPss - val nativePss = debugMemoryInfo.nativePss - val otherPss = debugMemoryInfo.otherPss - - var cpuTimeDeltaMs = 0L - var cpuPercentage = 0.0 - try { - val statFile = File("/proc/$pid/stat") - BufferedReader(FileReader(statFile)).use { reader -> - val line = reader.readLine() - if (line != null) { - val parts = line.split(" ") - if (parts.size >= 17) { - val utime = parts[13].toLong() - val stime = parts[14].toLong() - val currentCpuTime = utime + stime - - if (lastCpuTime > 0) { - val cpuJiffiesDelta = currentCpuTime - lastCpuTime - cpuTimeDeltaMs = cpuJiffiesDelta * cpuJiffyToMs - - val totalAvailableCpuTimeMs = loggingIntervalMs * numberOfCores - if (totalAvailableCpuTimeMs > 0) { - cpuPercentage = (cpuTimeDeltaMs.toDouble() / totalAvailableCpuTimeMs.toDouble()) * 100.0 - } - } - lastCpuTime = currentCpuTime - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error reading /proc/$pid/stat: ${e.message}") - } - - val file = File(filesDir, resourceLogFile) - FileWriter(file, true).use { writer -> - if (!file.exists() || file.length() == 0L) { - writer.append("Timestamp,CPU_Time_Delta_ms,CPU_Percentage,Total_PSS_KB,Dalvik_PSS_KB,Native_PSS_KB,Other_PSS_KB\n") - } - writer.append("$timestamp,$cpuTimeDeltaMs,${String.format("%.2f", cpuPercentage)},$totalPss,$dalvikPss,$nativePss,$otherPss\n") - } - Log.d(RESOURCE_LOG_TAG, "Logged: CPU Delta=${cpuTimeDeltaMs}ms, CPU%=${String.format("%.2f", cpuPercentage)}, PSS=${totalPss}KB, Dalvik=${dalvikPss}KB, Native=${nativePss}KB, Other=${otherPss}KB") - - } catch (e: Exception) { - Log.e(TAG, "Error logging resource usage: ${e.message}") - } - } - - private fun printResourceLogToLogcat() { - resourceLoggingScope.launch { - try { - val file = File(filesDir, resourceLogFile) - if (file.exists()) { - BufferedReader(FileReader(file)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(RESOURCE_LOG_OUTPUT_TAG, line ?: "") - } - } - Log.d(RESOURCE_LOG_OUTPUT_TAG, "--- End of Resource Log ---") - } else { - Log.d(RESOURCE_LOG_OUTPUT_TAG, "Resource log file not found.") - } - } catch (e: IOException) { - Log.e(RESOURCE_LOG_OUTPUT_TAG, "Error reading resource log: ${e.message}") - } - } - } - - override fun onDestroy() { - resourceLoggingJob?.cancel() - super.onDestroy() - } - - private fun addMessage(message: ChatMessage) { - messages.add(message) - chatAdapter.notifyItemInserted(messages.size - 1) - recyclerView.post { - recyclerView.scrollToPosition(messages.size - 1) - } - } - - private fun removeLastLoadingMessage() { - val lastIndex = messages.size - 1 - if (lastIndex >= 0 && messages[lastIndex] is ChatMessage.Loading) { - messages.removeAt(lastIndex) - chatAdapter.notifyItemRemoved(lastIndex) - } - } - - 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) { - Log.e(TAG, "Failed to parse A2UI payload", e) - emptyList() - } - - // Because the streaming data might be parsed into several incomplete ParsedA2AEvent.Data blocks, - // we extract and aggregate them into a single, valid JSON array here to prevent crashes when passed to the frontend. - var aggregatedText = StringBuilder() - var aggregatedJson = JSONArray() - - for (part in parsedParts) { - when (part) { - is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Text -> { - if (aggregatedText.isNotEmpty()) aggregatedText.append("\n") - aggregatedText.append(part.text) - } - 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)) - } - } catch (e: Exception) { - Log.e(TAG, "Error aggregating JSON data", e) - } - } - } - } - } - - val finalConversationalText = aggregatedText.toString() - val finalA2uiJson = if (aggregatedJson.length() > 0) aggregatedJson.toString() else "" - - if (finalConversationalText.isNotEmpty() || finalA2uiJson.isNotEmpty()) { - if (finalConversationalText.isNotEmpty()) { - currentAgentTextIndex?.let { idx -> - messages[idx] = ChatMessage.Text(finalConversationalText, false) - chatAdapter.notifyItemChanged(idx) - } ?: run { - messages.add(ChatMessage.Text(finalConversationalText, false)) - val newIdx = messages.size - 1 - currentAgentTextIndex = newIdx - chatAdapter.notifyItemInserted(newIdx) - scrollToLastMessage() - } - } - if (finalA2uiJson.isNotEmpty() && finalA2uiJson != "[]") { - currentAgentA2UIIndex?.let { idx -> - val oldMsg = messages[idx] as? ChatMessage.GmpA2UIView - messages[idx] = ChatMessage.GmpA2UIView(finalA2uiJson, oldMsg?.startTime ?: System.currentTimeMillis()) - - val viewHolder = recyclerView.findViewHolderForAdapterPosition(idx) - if (viewHolder is ChatAdapter.GmpA2UIViewHolder) { - viewHolder.updateA2uiJson(finalA2uiJson) - } else { - chatAdapter.notifyItemChanged(idx) - } - } ?: run { - val gmpViewStartTime = System.currentTimeMillis() - messages.add(ChatMessage.GmpA2UIView(finalA2uiJson, gmpViewStartTime)) - val newIdx = messages.size - 1 - currentAgentA2UIIndex = newIdx - chatAdapter.notifyItemInserted(newIdx) - scrollToLastMessage() - } - } - } + editTextMessage.text.clear() + } } - - private fun logLatency(type: String, latencyMs: Long, status: String) { - lifecycleScope.launch(Dispatchers.IO) { - try { - val file = File(filesDir, latencyLogFile) - val writer = FileWriter(file, true) // Append mode - - if (!file.exists() || file.length() == 0L) { - writer.append("Timestamp,Type,Latency (ms),Status\n") - } - - val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) - writer.append("$timestamp,$type,$latencyMs,$status\n") - writer.flush() - writer.close() - Log.d(TAG, "$type Latency logged: $latencyMs ms, Status: $status") - } catch (e: IOException) { - Log.e(TAG, "Error logging latency: ${e.message}") - } - } + lifecycleScope.launch { + viewModel.uiState.collect { messages -> + chatAdapter.updateMessages(messages) + scrollToLastMessage(messages.size) + } } + } - private fun printLatencyLogToLogcat() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val file = File(filesDir, latencyLogFile) - if (file.exists()) { - BufferedReader(FileReader(file)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(LATENCY_TAG, line ?: "") - } - } - Log.d(LATENCY_TAG, "--- End of Latency Log ---") - } else { - Log.d(LATENCY_TAG, "Latency log file not found.") - } - } catch (e: IOException) { - Log.e(LATENCY_TAG, "Error reading latency log: ${e.message}") - } - } + private fun scrollToLastMessage(size: Int) { + if (size > 0 && recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { + recyclerView.post { recyclerView.scrollToPosition(size - 1) } } + } - private val apiKey = BuildConfig.GATEWAY_API_KEY - - private fun discoverProtocol() { - if (hasDiscoveredProtocol) { - return - } - hasDiscoveredProtocol = true - - val request = Request.Builder() - .url("$baseUrl/apps/$appName/users/user/sessions?key=$apiKey") - .post("{}".toRequestBody("application/json".toMediaType())) - .build() - - try { - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val body = response.body.string() ?: "" - if (body.isNotEmpty()) { - try { - val json = JSONObject(body) - val id = if (json.has("id")) json.optString("id") else null - if (id != null && id.isNotEmpty()) { - activeSessionId = id - useSseProtocol = true - Log.d(TAG, "Discovered ADK Web Server (SSE) protocol") - } else { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } catch(e: Exception) { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } else { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } else { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } - } catch (e: IOException) { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol on failure") - } - } - - public fun callPythonServer(userMessage: JSONObject) { - runOnUiThread { addMessage(ChatMessage.Loading) } - - // --- CANNED PROMPT INTERCEPTION --- - val textStr = userMessage.optString("text") - - var fileMap: Map = emptyMap() - try { - val mappingJson = assets.open("canned_responses/mapping.json").bufferedReader().use { it.readText() } - val mapObj = JSONObject(mappingJson) - val tempMap = mutableMapOf() - for (key in mapObj.keys()) { - val value = mapObj.getString(key) - // Ensure value points to the correct new directory name - val correctValue = if (value.startsWith("prompt_")) "canned_responses/$value" else value.replace("canned_prompts", "canned_responses") - tempMap[key] = correctValue - } - fileMap = tempMap - } catch (e: Exception) { - Log.e(TAG, "Error loading mapping.json", e) - } - - if (textStr.isNotEmpty()) { - val fileName = fileMap[textStr] - if (fileName != null) { - try { - val jsonString = assets.open(fileName).bufferedReader().use { it.readText() } - val cannedResponse = JSONObject(jsonString) - Log.d(TAG, "Using canned response from $fileName") - // Simulate network delay - lifecycleScope.launch(Dispatchers.IO) { - delay(2000) - runOnUiThread { - removeLastLoadingMessage() - processJsonResponse(cannedResponse) - } - } - return - } catch (e: Exception) { - Log.e(TAG, "Error loading canned response", e) - } - } - } - - val startTime = System.currentTimeMillis() - val currentCookie = traceCookie.incrementAndGet() - // Trace.beginAsyncSection("Server Response", currentCookie) - lifecycleScope.launch(Dispatchers.IO) { - discoverProtocol() - val partsArray = JSONArray() - if (userMessage.has("text")) { - partsArray.put(JSONObject().apply { - put("text", userMessage.optString("text")) - }) - } else if (userMessage.has("userAction")) { - partsArray.put(JSONObject().apply { - put("data", JSONObject().apply { - put("userAction", userMessage.opt("userAction")) - }) - }) - } - - val request: Request - if (useSseProtocol) { - val json = JSONObject().apply { - put("appName", appName) - put("userId", "user") - put("sessionId", activeSessionId ?: "") - put("newMessage", JSONObject().apply { - put("role", "user") - put("parts", partsArray) - }) - } - val body = json.toString().toRequestBody("application/json".toMediaType()) - val requestBuilder = Request.Builder() - .url("$baseUrl/run_sse") - .post(body) - .addHeader("Content-Type", "application/json") - if (activeServer == ServerType.DEMO) { - requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") - } - request = requestBuilder.build() - } else { - val json = JSONObject().apply { - put("jsonrpc", "2.0") - 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()) - val requestBuilder = Request.Builder() - .url("$baseUrl/?key=$apiKey") - .post(body) - .addHeader("Content-Type", "application/json") - if (activeServer == ServerType.DEMO) { - requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") - } - request = requestBuilder.build() - } - - try { - currentActiveCall = client.newCall(request) - currentActiveCall?.execute()?.use { response -> - val endTime = System.currentTimeMillis() - val latency = endTime - startTime - // Trace.endAsyncSection("Server Response", currentCookie) - logLatency("Server", latency, if (response.isSuccessful) "Success" else "Failure") - runOnUiThread { removeLastLoadingMessage() } - if (!response.isSuccessful) { - runOnUiThread { - addMessage(ChatMessage.Text("Error: ${response.code} - ${response.message}", false)) - } - return@use - } - handleSuccessfulResponse(response) - } - } catch (e: IOException) { - val endTime = System.currentTimeMillis() - val latency = endTime - startTime - // Trace.endAsyncSection("Server Response", currentCookie) - logLatency("Server", latency, "Network Error") - runOnUiThread { - removeLastLoadingMessage() - addMessage(ChatMessage.Text("Network Error: ${e.message}", false)) - } - } - } - } - - // The LLM's streaming response (SSE) arrives in fragmented chunks. - // We use this StringBuilder to accumulate all the text chunks and assemble them into a complete JSON string before parsing. - private var globalSseAccumulator = StringBuilder() - - private fun handleSuccessfulResponse(response: okhttp3.Response) { - if (useSseProtocol || response.header("Content-Type")?.contains("text/event-stream") == true) { - val source = response.body.source() ?: return - globalSseAccumulator.clear() - while (!source.exhausted()) { - val line = source.readUtf8Line() - if (line != null && line.startsWith("data: ")) { - val data = line.removePrefix("data: ") - if (data.isNotEmpty() && data != "[DONE]") { - try { - val jsonObj = JSONObject(data) - // Extract text delta to accumulate - var textDelta = "" - if (jsonObj.has("parts")) { - 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") - } - } - } - } - if (textDelta.isNotEmpty()) { - globalSseAccumulator.append(textDelta) - } - - // 1. Update the native Android text bubble with the accumulated conversation text - if (globalSseAccumulator.isNotEmpty()) { - val textUpdate = JSONObject().put("parts", JSONArray().put(JSONObject().put("text", globalSseAccumulator.toString()))) - runOnUiThread { processJsonResponse(textUpdate) } - } - - // 2. Pass the raw JSON chunk to the WebView. - // The frontend (AppMobile.tsx) now handles hallucination fixes, path resolution, and text deduplication. - runOnUiThread { processJsonResponse(jsonObj) } - } catch (e: Exception) { - Log.e(TAG, "Error parsing SSE data: $data", e) - } - } - } - } - } else { - val responseData = response.body.string() ?: return - runOnUiThread { - try { - val jsonResponse = JSONObject(responseData) - val result = jsonResponse.opt("result") - if (result is JSONObject) { - processJsonResponse(result) - } else if (result is String) { - try { - val inner = JSONObject(result) - processJsonResponse(inner) - } catch (e: Exception) { - processJsonResponse(jsonResponse) - } - } else { - processJsonResponse(jsonResponse) - } - } catch (e: Exception) { - addMessage(ChatMessage.Text("Error parsing JSON: ${e.message}", false)) - } - } - } - } - - public fun scrollToLastMessage() { - runOnUiThread { - if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { - recyclerView.post { - recyclerView.scrollToPosition(messages.size - 1) - } - } - } - } - - companion object { - private const val TAG = "MainActivity" - private const val LATENCY_TAG = "LatencyLog" - private const val RESOURCE_LOG_TAG = "ResourceLog" - private const val RESOURCE_LOG_OUTPUT_TAG = "ResourceLogOutput" - private const val MAX_ITEM_VIEW_CACHE_SIZE = 10 - } + companion object { + private const val TAG = "MainActivity" + } } diff --git a/client/android/app/src/main/java/com/example/maui/data/AgentResponse.kt b/client/android/app/src/main/java/com/example/maui/data/AgentResponse.kt new file mode 100644 index 0000000..f796197 --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/data/AgentResponse.kt @@ -0,0 +1,23 @@ +// +// 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. +// + +package com.example.maui.data + +data class AgentResponse( + val conversationalText: String, + val a2uiJson: String, + val isCanned: Boolean = false, +) 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 new file mode 100644 index 0000000..c372a6c --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/data/ChatRepository.kt @@ -0,0 +1,305 @@ +// +// 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. +// + +package com.example.maui.data + +import android.content.Context +import android.util.Log +import com.example.maui.BuildConfig +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +class ChatRepository(private val context: Context) { + private val applicationContext = context.applicationContext + private var useSseProtocol: Boolean? = null + private var activeSessionId: String? = null + private var hasDiscoveredProtocol = false + private val discoveryMutex = Mutex() + + private val client = + OkHttpClient.Builder() + .connectTimeout(300, TimeUnit.SECONDS) + .readTimeout(300, TimeUnit.SECONDS) + .writeTimeout(300, TimeUnit.SECONDS) + .build() + + enum class ServerType { + DEMO, + VANILLA, + } + + enum class DeviceType { + PHYSICAL, + EMULATOR, + } + + // --- CONFIGURATION --- + val activeServer = ServerType.DEMO + private val deviceType = DeviceType.EMULATOR + // --------------------- + + 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" + } + } + + val appName: String + get() = + when (activeServer) { + ServerType.DEMO -> "hello_world_agent" + ServerType.VANILLA -> "my_agent" + } + + private val apiKey = BuildConfig.GATEWAY_API_KEY + private val contextId = UUID.randomUUID().toString() + + suspend fun discoverProtocol(): Boolean = + kotlinx.coroutines.withContext(Dispatchers.IO) { + discoveryMutex.withLock { + if (hasDiscoveredProtocol) { + return@withContext useSseProtocol ?: false + } + hasDiscoveredProtocol = true + + val request = + Request.Builder() + .url("$baseUrl/apps/$appName/users/user/sessions?key=$apiKey") + .post("{}".toRequestBody("application/json".toMediaType())) + .build() + + try { + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + if (body.isNotEmpty()) { + try { + val json = JSONObject(body) + val id = if (json.has("id")) json.optString("id") else null + if (id != null && id.isNotEmpty()) { + activeSessionId = id + useSseProtocol = true + Log.d(TAG, "Discovered ADK Web Server (SSE) protocol") + return@withContext true + } + } catch (e: Exception) { + Log.d(TAG, "Error parsing session response", e) + } + } + } + } + } catch (e: IOException) { + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol on failure") + } + useSseProtocol = false + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") + return@withContext false + } + } + + fun isCannedPrompt(text: String): Boolean { + return getCannedResponse(text) != null + } + + fun getCannedResponse(text: String): JSONObject? { + try { + val mappingJson = + applicationContext.assets.open("canned_responses/mapping.json").bufferedReader().use { + 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) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error loading mapping.json or canned response", e) + } + return null + } + + private fun buildRequest(useSse: Boolean, 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 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) + } + + return requestBuilder.build() + } + + 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) + } + return textDelta + } + + fun callPythonServer(userMessage: JSONObject): Flow> = + flow { + val textStr = userMessage.optString("text") + val bypassCanned = userMessage.optBoolean("bypassCanned", false) + val cannedResponse = if (!bypassCanned) getCannedResponse(textStr) else null + + if (cannedResponse != null) { + delay(2000) // Simulate network delay + emit(Result.success(AgentResponse("", cannedResponse.toString(), isCanned = true))) + return@flow + } + + val useSse = discoverProtocol() + val partsArray = JSONArray() + if (userMessage.has("text")) { + partsArray.put(JSONObject().apply { put("text", userMessage.optString("text")) }) + } else if (userMessage.has("userAction")) { + partsArray.put( + JSONObject().apply { + put("data", JSONObject().apply { put("userAction", userMessage.opt("userAction")) }) + } + ) + } + + val request = buildRequest(useSse, partsArray) + + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + emit(Result.failure(Exception("Error: ${response.code} - ${response.message}"))) + 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) + } + 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) { + emit(Result.failure(Exception("Network Error: ${e.message}"))) + } + } + .flowOn(Dispatchers.IO) + + companion object { + private const val TAG = "ChatRepository" + private const val SSE_DATA_PREFIX = "data: " + private const val SSE_DONE_MESSAGE = "[DONE]" + private const val JSON_RPC_VERSION = "2.0" + } +} diff --git a/client/android/app/src/main/java/com/example/maui/telemetry/LatencyLogger.kt b/client/android/app/src/main/java/com/example/maui/telemetry/LatencyLogger.kt new file mode 100644 index 0000000..50e1632 --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/telemetry/LatencyLogger.kt @@ -0,0 +1,82 @@ +// +// 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. +// + +package com.example.maui.telemetry + +import android.content.Context +import android.util.Log +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LatencyLogger(context: Context) { + private val applicationContext = context.applicationContext + private val latencyLogFile = "latency_log.csv" + + suspend fun logLatency(type: String, latencyMs: Long, status: String) { + withContext(Dispatchers.IO) { + try { + val file = File(applicationContext.filesDir, latencyLogFile) + val writer = FileWriter(file, true) // Append mode + + if (!file.exists() || file.length() == 0L) { + writer.append("Timestamp,Type,Latency (ms),Status\n") + } + + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + writer.append("$timestamp,$type,$latencyMs,$status\n") + writer.flush() + writer.close() + Log.d(TAG, "$type Latency logged: $latencyMs ms, Status: $status") + } catch (e: IOException) { + Log.e(TAG, "Error logging latency: ${e.message}") + } + } + } + + suspend fun printLatencyLog() { + withContext(Dispatchers.IO) { + try { + val file = File(applicationContext.filesDir, latencyLogFile) + if (file.exists()) { + BufferedReader(FileReader(file)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + Log.d(LATENCY_TAG, line ?: "") + } + } + Log.d(LATENCY_TAG, "--- End of Latency Log ---") + } else { + Log.d(LATENCY_TAG, "Latency log file not found.") + } + } catch (e: IOException) { + Log.e(LATENCY_TAG, "Error reading latency log: ${e.message}") + } + } + } + + companion object { + private const val TAG = "LatencyLogger" + private const val LATENCY_TAG = "[MAUI] LatencyLog" + } +} diff --git a/client/android/app/src/main/java/com/example/maui/telemetry/ResourceLogger.kt b/client/android/app/src/main/java/com/example/maui/telemetry/ResourceLogger.kt new file mode 100644 index 0000000..0cb18df --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/telemetry/ResourceLogger.kt @@ -0,0 +1,153 @@ +// +// 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. +// + +package com.example.maui.telemetry + +import android.app.ActivityManager +import android.content.Context +import android.os.Process +import android.util.Log +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ResourceLogger(context: Context) { + private val applicationContext = context.applicationContext + private val resourceLogFile = "resource_log.csv" + private var lastCpuTime: Long = 0 + private val cpuJiffyToMs = 10L + private val loggingIntervalMs = 1000L + private var resourceLoggingJob: Job? = null + private val numberOfCores = Runtime.getRuntime().availableProcessors() + + fun startLogging(scope: CoroutineScope) { + resourceLoggingJob?.cancel() + resourceLoggingJob = + scope.launch(Dispatchers.IO) { + while (isActive) { + logResourceUsage() + delay(loggingIntervalMs) + } + } + } + + fun stopLogging() { + resourceLoggingJob?.cancel() + } + + private fun logResourceUsage() { + try { + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + val activityManager = + applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val pid = Process.myPid() + val memoryInfo = activityManager.getProcessMemoryInfo(intArrayOf(pid)) + val debugMemoryInfo = memoryInfo[0] + val totalPss = debugMemoryInfo.totalPss + val dalvikPss = debugMemoryInfo.dalvikPss + val nativePss = debugMemoryInfo.nativePss + val otherPss = debugMemoryInfo.otherPss + + var cpuTimeDeltaMs = 0L + var cpuPercentage = 0.0 + try { + val statFile = File("/proc/$pid/stat") + BufferedReader(FileReader(statFile)).use { reader -> + val line = reader.readLine() + if (line != null) { + val parts = line.split(" ") + if (parts.size >= 17) { + val utime = parts[13].toLong() + val stime = parts[14].toLong() + val currentCpuTime = utime + stime + + if (lastCpuTime > 0L) { + val cpuJiffiesDelta = currentCpuTime - lastCpuTime + cpuTimeDeltaMs = cpuJiffiesDelta * cpuJiffyToMs + + val totalAvailableCpuTimeMs = loggingIntervalMs * numberOfCores + if (totalAvailableCpuTimeMs > 0) { + cpuPercentage = + (cpuTimeDeltaMs.toDouble() / totalAvailableCpuTimeMs.toDouble()) * 100.0 + } + } + lastCpuTime = currentCpuTime + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error reading /proc/$pid/stat: ${e.message}") + } + + val file = File(applicationContext.filesDir, resourceLogFile) + FileWriter(file, true).use { writer -> + if (!file.exists() || file.length() == 0L) { + writer.append( + "Timestamp,CPU_Time_Delta_ms,CPU_Percentage,Total_PSS_KB,Dalvik_PSS_KB,Native_PSS_KB,Other_PSS_KB\n" + ) + } + writer.append( + "$timestamp,$cpuTimeDeltaMs,${String.format("%.2f", cpuPercentage)},$totalPss,$dalvikPss,$nativePss,$otherPss\n" + ) + } + Log.d( + RESOURCE_LOG_TAG, + "Logged: CPU Delta=${cpuTimeDeltaMs}ms, CPU%=${String.format("%.2f", cpuPercentage)}, PSS=${totalPss}KB, Dalvik=${dalvikPss}KB, Native=${nativePss}KB, Other=${otherPss}KB", + ) + } catch (e: Exception) { + Log.e(TAG, "Error logging resource usage: ${e.message}") + } + } + + suspend fun printResourceLog() { + withContext(Dispatchers.IO) { + try { + val file = File(applicationContext.filesDir, resourceLogFile) + if (file.exists()) { + BufferedReader(FileReader(file)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + Log.d(RESOURCE_LOG_OUTPUT_TAG, line ?: "") + } + } + Log.d(RESOURCE_LOG_OUTPUT_TAG, "--- End of Resource Log ---") + } else { + Log.d(RESOURCE_LOG_OUTPUT_TAG, "Resource log file not found.") + } + } catch (e: IOException) { + Log.e(RESOURCE_LOG_OUTPUT_TAG, "Error reading resource log: ${e.message}") + } + } + } + + companion object { + private const val TAG = "ResourceLogger" + private const val RESOURCE_LOG_TAG = "ResourceLog" + private const val RESOURCE_LOG_OUTPUT_TAG = "ResourceLogOutput" + } +} 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 new file mode 100644 index 0000000..d6f4396 --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/ui/ChatViewModel.kt @@ -0,0 +1,214 @@ +// +// 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. +// + +package com.example.maui.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.maui.ChatMessage +import com.example.maui.data.ChatRepository +import com.example.maui.telemetry.ResourceLogger +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject + +class ChatViewModel( + private val repository: ChatRepository, + private val resourceLogger: ResourceLogger, +) : ViewModel() { + + private val _uiState = MutableStateFlow>(emptyList()) + val uiState: StateFlow> = _uiState.asStateFlow() + + private var currentRequestJob: Job? = null + private var currentAgentTextIndex: Int? = null + private var currentAgentA2UIIndex: Int? = null + + init { + resourceLogger.startLogging(viewModelScope) + } + + fun sendMessage(text: String, isGrounding: Boolean = false, bypassCanned: Boolean = false) { + currentRequestJob?.cancel() + currentAgentTextIndex = null + currentAgentA2UIIndex = null + val serverMessageText = if (isGrounding) "[GROUNDING] $text" else text + addMessage(ChatMessage.Text(text, true)) + val jsonObject = + JSONObject().apply { + put("text", serverMessageText) + put("bypassCanned", bypassCanned) + } + sendPayload(jsonObject) + } + + fun handleAgentAction(actionName: String, contextJson: String) { + val userAction = + JSONObject().apply { + put("name", actionName) + put("context", JSONObject(contextJson)) + } + val jsonObject = JSONObject().apply { put("userAction", userAction) } + sendPayload(jsonObject) + } + + 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)) + } + } + } + .onFailure { exception -> + removeLastLoadingMessage() + addMessage(ChatMessage.Text(exception.message ?: "Unknown Error", false)) + } + } + } + } + + private fun addMessage(message: ChatMessage) { + _uiState.update { currentList -> currentList + message } + } + + private fun removeLastLoadingMessage() { + _uiState.update { currentList -> + if (currentList.isNotEmpty() && currentList.last() is ChatMessage.Loading) { + currentList.dropLast(1) + } else { + currentList + } + } + } + + 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() + + for (part in parsedParts) { + when (part) { + is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Text -> { + if (aggregatedText.isNotEmpty()) aggregatedText.append("\n") + aggregatedText.append(part.text) + } + 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)) + } + } catch (e: Exception) {} + } + } + } + } + + val finalConversationalText = aggregatedText.toString() + val finalA2uiJson = if (aggregatedJson.length() > 0) aggregatedJson.toString() else "" + + 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 (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 + } + } + mutableList.toList() + } + } + } + + override fun onCleared() { + resourceLogger.stopLogging() + super.onCleared() + } + + companion object { + fun provideFactory( + repository: ChatRepository, + resourceLogger: ResourceLogger, + ): androidx.lifecycle.ViewModelProvider.Factory = + object : androidx.lifecycle.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/drawable/rounded_corner.xml b/client/android/app/src/main/res/drawable/rounded_corner.xml index 2944f80..65b8ed1 100644 --- a/client/android/app/src/main/res/drawable/rounded_corner.xml +++ b/client/android/app/src/main/res/drawable/rounded_corner.xml @@ -1,6 +1,6 @@