From 113a46516459ed288f5dbaa11577df716c64c356 Mon Sep 17 00:00:00 2001 From: chengfeitao Date: Wed, 17 Jun 2026 20:17:18 +0000 Subject: [PATCH] feat: Sync from internal staging. --- README.md | 42 +- client/android/.gitignore | 14 + client/android/README.md | 81 ++ client/android/app/build.gradle | 89 +++ .../android/app/src/main/AndroidManifest.xml | 41 + .../main/assets/canned_responses/mapping.json | 7 + .../assets/canned_responses/prompt_1.json | 115 +++ .../assets/canned_responses/prompt_2.json | 68 ++ .../assets/canned_responses/prompt_3.json | 69 ++ .../assets/canned_responses/prompt_4.json | 98 +++ .../assets/canned_responses/prompt_5.json | 203 +++++ .../main/java/com/example/maui/ChatAdapter.kt | 142 ++++ .../main/java/com/example/maui/ChatMessage.kt | 23 + .../java/com/example/maui/MainActivity.kt | 717 ++++++++++++++++++ .../src/main/res/drawable/rounded_corner.xml | 20 + .../main/res/drawable/rounded_corner_user.xml | 20 + .../app/src/main/res/layout/activity_main.xml | 95 +++ .../app/src/main/res/layout/item_loading.xml | 36 + .../res/layout/item_maui_gmp_a2ui_view.xml | 27 + .../app/src/main/res/layout/item_text.xml | 33 + .../android/app/src/main/res/mipmap/.gitkeep | 0 .../app/src/main/res/mipmap/ic_launcher.xml | 20 + .../src/main/res/mipmap/ic_launcher_round.xml | 20 + .../app/src/main/res/values/colors.xml | 25 + .../app/src/main/res/values/strings.xml | 19 + .../app/src/main/res/values/themes.xml | 32 + client/android/build.gradle | 23 + client/android/gradle.properties | 21 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + client/android/gradlew | 251 ++++++ client/android/gradlew.bat | 94 +++ client/android/settings.gradle | 36 + client/ios/.gitignore | 3 + client/ios/A2UI-Example-Info.plist | 11 + .../A2UI-Example.xcodeproj/project.pbxproj | 502 ++++++++++++ .../contents.xcworkspacedata | 7 + .../A2UI_ExampleUITests.swift | 107 +++ client/ios/ChatApp.swift | 26 + client/ios/ChatView.swift | 331 ++++++++ client/ios/ChatViewModel.swift | 336 ++++++++ client/ios/Info.plist | 49 ++ client/ios/Models.swift | 58 ++ client/ios/README.md | 103 +++ client/ios/run_xcode_ui_tests.sh | 27 + client/web/react/package-lock.json | 101 ++- client/web/react/package.json | 4 +- client/web/react/src/AppMobile.tsx | 244 ++++++ client/web/react/src/main.tsx | 16 +- client/web/react/src/utils/platform.ts | 25 + client/web/react/vite.config.ts | 46 +- 51 files changed, 4463 insertions(+), 21 deletions(-) create mode 100644 client/android/.gitignore create mode 100644 client/android/README.md create mode 100644 client/android/app/build.gradle create mode 100644 client/android/app/src/main/AndroidManifest.xml create mode 100644 client/android/app/src/main/assets/canned_responses/mapping.json create mode 100644 client/android/app/src/main/assets/canned_responses/prompt_1.json create mode 100644 client/android/app/src/main/assets/canned_responses/prompt_2.json create mode 100644 client/android/app/src/main/assets/canned_responses/prompt_3.json create mode 100644 client/android/app/src/main/assets/canned_responses/prompt_4.json create mode 100644 client/android/app/src/main/assets/canned_responses/prompt_5.json create mode 100644 client/android/app/src/main/java/com/example/maui/ChatAdapter.kt create mode 100644 client/android/app/src/main/java/com/example/maui/ChatMessage.kt create mode 100644 client/android/app/src/main/java/com/example/maui/MainActivity.kt create mode 100644 client/android/app/src/main/res/drawable/rounded_corner.xml create mode 100644 client/android/app/src/main/res/drawable/rounded_corner_user.xml create mode 100644 client/android/app/src/main/res/layout/activity_main.xml create mode 100644 client/android/app/src/main/res/layout/item_loading.xml create mode 100644 client/android/app/src/main/res/layout/item_maui_gmp_a2ui_view.xml create mode 100644 client/android/app/src/main/res/layout/item_text.xml create mode 100644 client/android/app/src/main/res/mipmap/.gitkeep create mode 100644 client/android/app/src/main/res/mipmap/ic_launcher.xml create mode 100644 client/android/app/src/main/res/mipmap/ic_launcher_round.xml create mode 100644 client/android/app/src/main/res/values/colors.xml create mode 100644 client/android/app/src/main/res/values/strings.xml create mode 100644 client/android/app/src/main/res/values/themes.xml create mode 100644 client/android/build.gradle create mode 100644 client/android/gradle.properties create mode 100644 client/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 client/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 client/android/gradlew create mode 100644 client/android/gradlew.bat create mode 100644 client/android/settings.gradle create mode 100644 client/ios/.gitignore create mode 100644 client/ios/A2UI-Example-Info.plist create mode 100644 client/ios/A2UI-Example.xcodeproj/project.pbxproj create mode 100644 client/ios/A2UI-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift create mode 100644 client/ios/ChatApp.swift create mode 100644 client/ios/ChatView.swift create mode 100644 client/ios/ChatViewModel.swift create mode 100644 client/ios/Info.plist create mode 100644 client/ios/Models.swift create mode 100644 client/ios/README.md create mode 100755 client/ios/run_xcode_ui_tests.sh create mode 100644 client/web/react/src/AppMobile.tsx create mode 100644 client/web/react/src/utils/platform.ts diff --git a/README.md b/README.md index c9c9028..89e6b04 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Welcome to the **Maps Agentic UI Toolkit Samples**! 🎉 -This repository (`a2ui-samples`) contains reference samples for the Maps Agentic UI Toolkit. It provides a fully working, interactive sample application implementing the Agent-to-User Interface (A2UI) standard, allowing AI agents to present rich, dynamic map interfaces directly in your web browser. +This repository (`a2ui-samples`) contains reference samples for the Maps Agentic UI Toolkit. It provides fully working, interactive sample applications implementing the Agent-to-User Interface (A2UI) standard, allowing AI agents to present rich, dynamic map interfaces directly across Web, Android, and iOS platforms. This project is intended for demonstration purposes to help you get up and running quickly! @@ -24,13 +24,20 @@ Here is an overview of how the directory structure looks when set up correctly: parent-folder/ ├── a2ui/ <-- SIBLING REPOSITORY (Core Toolkit) │ ├── agent/python-agent/ <-- Core Python agent libraries (maui-a2ui-python) -│ └── client/web/ <-- Core web UI component library (@googlemaps/a2ui) +│ └── client/ <-- Core client UI libraries +│ ├── web/ <-- Core Web UI component library (@googlemaps/a2ui) +│ ├── android/ <-- Android View component library (GoogleMapsA2UI) +│ └── ios/ <-- iOS SwiftUI component library (GoogleMapsA2UI) │ └── a2ui-samples/ <-- THIS REPOSITORY (Quickstart & Demos) ├── agent/python/ <-- Sample backend Python agent server - └── client/web/react/ <-- Sample frontend React web application + └── client/ <-- Sample client applications + ├── web/react/ <-- Sample frontend React web application + ├── android/ <-- Sample Android application + └── ios/ <-- Sample iOS application ``` + Understanding this layout ensures you will feel entirely comfortable linking the backend and frontend components in the quickstart steps below! ## 🚀 Quickstart Guide @@ -77,6 +84,10 @@ For more information about the environment variables, see the **Google API Key C `npm` is the standard package manager for JavaScript and TypeScript web applications, used to download frontend libraries and run development servers. * **Installation:** Download and install Node.js (which includes `npm`) from [https://nodejs.org/](https://nodejs.org/). +#### 4. Mobile Development Tools (Optional for mobile samples) +* **Android**: [Android Studio](https://developer.android.com/studio) to build and run the native Android sample app. +* **iOS**: macOS with [Xcode](https://developer.apple.com/xcode/) to build and run the native iOS sample app. + --- ### Step 1: Run the Backend (Python Agent) @@ -106,7 +117,11 @@ The backend server is powered by Python and runs our sample agent. --- -### Step 2: Run the Frontend (React Client) +### Step 2: Run a Client Application + +Once your backend agent is up and running, you can connect to it using any of our sample client platforms. + +#### Option A: Web Client (React) The frontend is a React web application that communicates with the backend agent and renders the interactive UI. @@ -119,6 +134,25 @@ The frontend is a React web application that communicates with the backend agent 2. **See the demo working live!** 🌟 Open [http://localhost:5173](http://localhost:5173) in your web browser to interact with your fully functioning Agentic UI demo! + +#### Option B: Android Client + +The Android sample is a native app utilizing Android Views and [GoogleMapsA2UI(Android)](https://github.com/googlemaps/a2ui/tree/main/client/android) to render agent-driven UI. + +1. **Open Project**: Open the `client/android` directory in Android Studio. +2. **Run**: Build and run the application. It will automatically connect to your running Python agent at `http://10.0.2.2:10002` (for an emulator) or `http://127.0.0.1:10002` (for a physical device). + +For more server configration and other detailed setup, refer to the [Android Sample README](client/android/README.md). + +#### Option C: iOS Client + +The iOS sample is a native app utilizing SwiftUI and the [GoogleMapsA2UI(iOS)](https://github.com/googlemaps/a2ui/tree/main/client/ios) to render agent-driven UI. + +1. **Setup**: Open the `.xcodeproj` file in the `client/ios` directory in Xcode (use a `.xcworkspace` file instead if your project uses one). +2. **Run**: Build and run the app in the simulator. It will automatically connect to your running Python agent at `http://localhost:10002`. + +For more server configration and other detailed setup, refer to the [iOS Sample README](client/ios/README.md). + ## Google API Keys ### Google Maps API Key diff --git a/client/android/.gitignore b/client/android/.gitignore new file mode 100644 index 0000000..edf0748 --- /dev/null +++ b/client/android/.gitignore @@ -0,0 +1,14 @@ +# Built artifacts +bin/ +gen/ +out/ +build/ +app/build/ + +# Gradle +.gradle/ +.kotlin/ +local.properties + +# Android Studio / IntelliJ +.idea/ diff --git a/client/android/README.md b/client/android/README.md new file mode 100644 index 0000000..1fe5321 --- /dev/null +++ b/client/android/README.md @@ -0,0 +1,81 @@ +# A2UI Android Sample App + +## Overview +This directory contains the Android sample application for the Google Maps Agentic UI (A2UI) Toolkit. It demonstrates how to integrate the underlying `GoogleMapsA2UI` Library to render generative AI responses containing interactive map elements and conversational text natively within an Android WebView. + +**Compatibility:** This sample app is designed for **A2UI v0.9**. It is not compatible with earlier versions (e.g., v0.8). + +## Instructions + +### 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. + +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`): + +```properties +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 +``` + +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`**: + * **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). + +*(Note: Before building this sample app, ensure you have built and published the `GoogleMapsA2UI` Android Library locally. See [a2ui/client/android/README.md](https://github.com/googlemaps/a2ui/tree/main/client/android/README.md) for instructions).* + +### 3. Server Configuration & Connectivity Options + +In `app/src/main/java/com/example/maui/MainActivity.kt`, verify the flags match your environment: + +#### Server Type (`activeServer`) +* `ServerType.DEMO`: Connects to the `GATEWAY_URL` specified in `local.properties`. Use this for your Remote Server or the local Demo Server. +* `ServerType.VANILLA`: Connects to a standalone Python agent running locally (e.g., `python -m my_agent --port 8000`). + +#### Device Type (`deviceType`) +* `DeviceType.PHYSICAL`: Use when testing on real Android devices. *(Note: If using a local Demo Server on a physical device, run `adb reverse tcp:10002 tcp:10002`)* +* `DeviceType.EMULATOR`: Use when testing on emulators. + +### 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): + ```bash + ./gradlew :app:installDebug + ``` + *(For release builds, use `./gradlew :app:installRelease`)* +3. Launch the app on your emulator or connected device: + ```bash + adb shell am start -n com.example.maui/.MainActivity + ``` + +### 5. Example Prompts & Canned Responses + +To facilitate rapid demonstration and UI testing, the sample app includes a dropdown list of frequently used example prompts. + +**Important Behavior Note:** +* **Example Prompts:** Selecting an example prompt from the dropdown menu will load a **local, pre-stored JSON response** (found in `assets/canned_responses`) instead of making a live call to the backend server. This is intended for consistent UI testing and fast demonstrations without LLM latency. + +## Troubleshooting + +### Debug Keystore Missing +If you receive an error: `Keystore file ... debug.keystore not found`, this means your local Android environment hasn’t generated a default debug key yet. + +**Solution:** +* **Option A (Recommended):** Open the project in **Android Studio** and let it perform a Gradle sync. This will automatically generate the keystore. +* **Option B (Manual):** Run the following command to generate one: + ```bash + keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US" + ``` diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle new file mode 100644 index 0000000..8293d6d --- /dev/null +++ b/client/android/app/build.gradle @@ -0,0 +1,89 @@ +// +// Copyright 2026 Google Inc. +// +// 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. +// + +plugins { + id 'com.android.application' + id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' +} + +android { + namespace 'com.example.maui' + compileSdk 34 + + defaultConfig { + applicationId "com.example.maui" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + buildConfig true + } + + signingConfigs { + debug { + // Use the default debug keystore + storeFile file(System.getProperty("user.home") + "/.android/debug.keystore") + storePassword "android" + keyAlias "androiddebugkey" + keyPassword "android" + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + signingConfig signingConfigs.debug + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + // Configure Java toolchain + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation 'androidx.tracing:tracing-ktx:1.2.0' + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation 'com.google.android.libraries.mapsplatform.a2ui:GoogleMapsA2UI:0.1.0' +} diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..90bb004 --- /dev/null +++ b/client/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/client/android/app/src/main/assets/canned_responses/mapping.json b/client/android/app/src/main/assets/canned_responses/mapping.json new file mode 100644 index 0000000..7a8c18b --- /dev/null +++ b/client/android/app/src/main/assets/canned_responses/mapping.json @@ -0,0 +1,7 @@ +{ + "Show me 5 coffee shops near South Lake Union in Seattle": "prompt_1.json", + "Is the Edgewater Hotel in Seattle a good hotel?": "prompt_2.json", + "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?": "prompt_3.json", + "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)": "prompt_4.json", + "Give me a 3 day itinerary for a family of 3 traveling to London": "prompt_5.json" +} 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 new file mode 100644 index 0000000..6746538 --- /dev/null +++ b/client/android/app/src/main/assets/canned_responses/prompt_1.json @@ -0,0 +1,115 @@ +{ + "result": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "coffee-shops", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "coffee-shops", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "intro-text", + "map", + "places-list" + ] + }, + { + "id": "intro-text", + "component": "Text", + "variant": "body", + "text": "Here are 5 highly-rated coffee shops near South Lake Union in Seattle. You can view their details on the map below." + }, + { + "id": "map", + "component": "GoogleMap", + "center": { + "lat": 47.6205, + "lng": -122.335 + }, + "zoom": 14, + "markers": [ + { + "lat": { + "path": "/coffeeshops/0/lat" + }, + "lng": { + "path": "/coffeeshops/0/lng" + }, + "label": "Matcha Magic" + }, + { + "lat": { + "path": "/coffeeshops/1/lat" + }, + "lng": { + "path": "/coffeeshops/1/lng" + }, + "label": "Evoke Cafe Bar" + } + ] + }, + { + "id": "places-list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "place-card", + "path": "/coffeeshops" + } + }, + { + "id": "place-card", + "component": "PlaceCard", + "placeId": { + "path": "placeId" + } + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "coffee-shops", + "path": "/", + "value": { + "coffeeshops": [ + { + "placeId": "places/ChIJMesSl2YVkFQR9cjxPkeMS_g", + "lat": 47.6233, + "lng": -122.334 + }, + { + "placeId": "places/ChIJq8K12DcVkFQRdPGoNaBvVUQ", + "lat": 47.6205, + "lng": -122.3395 + }, + { + "placeId": "places/ChIJjS-CKQ8VkFQRy_oDiA11YVE", + "lat": 47.6251, + "lng": -122.3369 + }, + { + "placeId": "places/ChIJdxfOMSYVkFQRT283uS_dls4", + "lat": 47.6189, + "lng": -122.3485 + }, + { + "placeId": "places/ChIJ9-eiZRIVkFQRCWD_--tX6BI", + "lat": 47.621, + "lng": -122.334 + } + ] + } + } + } + ] +} \ 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 new file mode 100644 index 0000000..7f6bd48 --- /dev/null +++ b/client/android/app/src/main/assets/canned_responses/prompt_2.json @@ -0,0 +1,68 @@ +{ + "result": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "edgewater", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "edgewater", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "intro-text", + "map-container", + "hotel-card" + ] + }, + { + "id": "intro-text", + "component": "Text", + "variant": "body", + "text": "The **Edgewater Hotel** is widely considered an excellent choice in Seattle. It is uniquely situated directly over the water, offering panoramic views of Elliott Bay and the Olympic Mountains.\n\nReviewers frequently highlight the exceptional staff and cozy lodge-style interiors. Its history includes hosting famous bands like The Beatles. It is also noted as being good for children and allowing dogs." + }, + { + "id": "map-container", + "component": "Card", + "child": "hotel-map" + }, + { + "id": "hotel-map", + "component": "GoogleMap", + "center": { + "lat": 47.612359, + "lng": -122.352237 + }, + "zoom": 17, + "markers": [ + { + "lat": 47.612359, + "lng": -122.352237, + "label": "The Edgewater Hotel" + } + ] + }, + { + "id": "hotel-card", + "component": "PlaceCard", + "placeId": "places/ChIJhdZMaVIVkFQR2USwWqzU5bA" + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "edgewater", + "path": "/", + "value": {} + } + } + ] +} \ 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 new file mode 100644 index 0000000..f2bda24 --- /dev/null +++ b/client/android/app/src/main/assets/canned_responses/prompt_3.json @@ -0,0 +1,69 @@ +{ + "result": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "commute", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "commute", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "intro-text", + "commute-map", + "place_card_1" + ] + }, + { + "id": "intro-text", + "component": "Text", + "variant": "body", + "text": "To commute from downtown Redmond to Google Kirkland (425 Urban Plz Suite#223) during morning rush hour, you can expect the drive to take approximately 20-30 minutes depending on traffic conditions.\n\nPlease note that actual travel times can vary based on real-time traffic incidents." + }, + { + "id": "commute-map", + "component": "GoogleMap", + "center": { + "lat": 47.6769, + "lng": -122.192 + }, + "zoom": 12, + "mode": "roadmap", + "routes": [ + { + "origin": { + "lat": 47.673988, + "lng": -122.121513 + }, + "destination": { + "lat": 47.6769, + "lng": -122.192 + } + } + ] + }, + { + "id": "place_card_1", + "component": "PlaceCard", + "placeId": "places/ChIJF0JJdpgTkFQR2xjapNaR-as" + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "commute", + "path": "/", + "value": {} + } + } + ] +} \ 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 new file mode 100644 index 0000000..ed7e329 --- /dev/null +++ b/client/android/app/src/main/assets/canned_responses/prompt_4.json @@ -0,0 +1,98 @@ +{ + "result": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "restaurants", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "restaurants", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "intro-text", + "directions-map", + "restaurant-list" + ] + }, + { + "id": "intro-text", + "component": "Text", + "variant": "body", + "text": "Here are 5 lunch restaurants with salads in South Lake Union. Below the list, I have provided directions to **Mendocino Farms** (the second restaurant on the list) from the Google South Lake Union WLK building." + }, + { + "id": "directions-map", + "component": "GoogleMap", + "center": { + "lat": 47.619089, + "lng": -122.337998 + }, + "zoom": 14, + "mode": "roadmap", + "routes": [ + { + "origin": { + "lat": 47.6288, + "lng": -122.3392 + }, + "destination": { + "lat": 47.6191, + "lng": -122.338 + } + } + ] + }, + { + "id": "restaurant-list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "restaurant-card-template", + "path": "/restaurants" + } + }, + { + "id": "restaurant-card-template", + "component": "PlaceCard", + "placeId": { + "path": "placeId" + } + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "restaurants", + "path": "/", + "value": { + "restaurants": [ + { + "placeId": "places/ChIJMazWCAsVkFQRvYTJ_UtIlSY" + }, + { + "placeId": "places/ChIJdVWO3S8VkFQRZZ3dtq-NkeQ" + }, + { + "placeId": "places/ChIJIzdKUjYVkFQRzOvdlIPhQ8I" + }, + { + "placeId": "places/ChIJKdsds2RrkFQRGNt_WeM6qUo" + }, + { + "placeId": "places/ChIJ9-eiZRIVkFQRCWD_--tX6BI" + } + ] + } + } + } + ] +} \ 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 new file mode 100644 index 0000000..5624dae --- /dev/null +++ b/client/android/app/src/main/assets/canned_responses/prompt_5.json @@ -0,0 +1,203 @@ +{ + "result": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "london", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "london", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "intro-text", + "london-map", + "day1-title", + "day1-list", + "day2-title", + "day2-list", + "day3-title", + "day3-list" + ] + }, + { + "id": "intro-text", + "component": "Text", + "variant": "body", + "text": "Here is a great 3-day itinerary for a family of 3 traveling to London. It perfectly balances historical sights with family-friendly activities!\n\n**Day 1: History & Views**\nStart your morning exploring the incredible artifacts at The British Museum. In the afternoon, head over to the Tower of London to see the Crown Jewels.\n\n**Day 2: City Landmarks**\nTake a ride on the London Eye for spectacular views of the city, followed by a relaxing stroll through Hyde Park.\n\n**Day 3: Science & Nature**\nSpend the day marveling at the dinosaur skeletons and interactive exhibits at the Natural History Museum." + }, + { + "id": "london-map", + "component": "GoogleMap", + "center": { + "lat": 51.5074, + "lng": -0.1278 + }, + "zoom": 12, + "mode": "roadmap", + "markers": [ + { + "lat": { + "path": "/day1/0/lat" + }, + "lng": { + "path": "/day1/0/lng" + }, + "label": "Tower of London", + "placeId": { + "path": "/day1/0/placeId" + } + }, + { + "lat": { + "path": "/day1/1/lat" + }, + "lng": { + "path": "/day1/1/lng" + }, + "label": "London Eye", + "placeId": { + "path": "/day1/1/placeId" + } + }, + { + "lat": { + "path": "/day2/0/lat" + }, + "lng": { + "path": "/day2/0/lng" + }, + "label": "British Museum", + "placeId": { + "path": "/day2/0/placeId" + } + }, + { + "lat": { + "path": "/day2/1/lat" + }, + "lng": { + "path": "/day2/1/lng" + }, + "label": "Buckingham Palace", + "placeId": { + "path": "/day2/1/placeId" + } + }, + { + "lat": { + "path": "/day3/0/lat" + }, + "lng": { + "path": "/day3/0/lng" + }, + "label": "Natural History Museum", + "placeId": { + "path": "/day3/0/placeId" + } + } + ] + }, + { + "id": "day1-title", + "component": "Text", + "variant": "title", + "text": "Day 1: History & Views" + }, + { + "id": "day1-list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "place-card", + "path": "/day1" + } + }, + { + "id": "day2-title", + "component": "Text", + "variant": "title", + "text": "Day 2: Culture & Royalty" + }, + { + "id": "day2-list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "place-card", + "path": "/day2" + } + }, + { + "id": "day3-title", + "component": "Text", + "variant": "title", + "text": "Day 3: Science & Nature" + }, + { + "id": "day3-list", + "component": "List", + "direction": "vertical", + "children": { + "componentId": "place-card", + "path": "/day3" + } + }, + { + "id": "place-card", + "component": "PlaceCard", + "placeId": { + "path": "placeId" + } + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "london", + "path": "/", + "value": { + "day1": [ + { + "placeId": "places/ChIJc2nSALkEdkgRkuoJJBfzkUI", + "lat": 51.5081, + "lng": -0.0759 + }, + { + "placeId": "places/ChIJ3TgfM0kDdkgRZ2TV4d1Jv6g", + "lat": 51.5033, + "lng": -0.1195 + } + ], + "day2": [ + { + "placeId": "places/ChIJB9OTMDIbdkgRp0JWbQGZsS8", + "lat": 51.5194, + "lng": -0.1269 + }, + { + "placeId": "places/ChIJtV5bzSAFdkgRpwLZFPWrJgo", + "lat": 51.5014, + "lng": -0.1419 + } + ], + "day3": [ + { + "placeId": "places/ChIJPy8Y5kIFdkgRxGSXw4Xjt3s", + "lat": 51.4967, + "lng": -0.1764 + } + ] + } + } + } + ] +} \ 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 new file mode 100644 index 0000000..63d373a --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/ChatAdapter.kt @@ -0,0 +1,142 @@ +// +// Copyright 2026 Google Inc. +// +// 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 android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +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") + } + } + + 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 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 + } + } + + class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + // ProgressBar is self-animating, no binding needed + } + + 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) {} + } + } + } + + fun bind(serverResponse: String, startTime: Long?, isLatestResponse: Boolean) { + gmpA2UIView.render(serverResponse, startTime) + } + + fun updateA2uiJson(newJson: String) { + gmpA2UIView.updateA2uiJson(newJson) + } + } + + companion object { + private const val TAG = "ChatAdapter" + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..6002d23 --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/ChatMessage.kt @@ -0,0 +1,23 @@ +// +// Copyright 2026 Google Inc. +// +// 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 + +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() +} 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 new file mode 100644 index 0000000..8ac4584 --- /dev/null +++ b/client/android/app/src/main/java/com/example/maui/MainActivity.kt @@ -0,0 +1,717 @@ +// 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 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.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 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" + ) + + 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() + } + } + } + } + + + 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}") + } + } + } + + 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 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 + } +} diff --git a/client/android/app/src/main/res/drawable/rounded_corner.xml b/client/android/app/src/main/res/drawable/rounded_corner.xml new file mode 100644 index 0000000..2944f80 --- /dev/null +++ b/client/android/app/src/main/res/drawable/rounded_corner.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/client/android/app/src/main/res/drawable/rounded_corner_user.xml b/client/android/app/src/main/res/drawable/rounded_corner_user.xml new file mode 100644 index 0000000..7fac927 --- /dev/null +++ b/client/android/app/src/main/res/drawable/rounded_corner_user.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/client/android/app/src/main/res/layout/activity_main.xml b/client/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..07f64a2 --- /dev/null +++ b/client/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + +