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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/android/app/src/main/res/layout/item_loading.xml b/client/android/app/src/main/res/layout/item_loading.xml
new file mode 100644
index 0000000..0503048
--- /dev/null
+++ b/client/android/app/src/main/res/layout/item_loading.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/android/app/src/main/res/layout/item_maui_gmp_a2ui_view.xml b/client/android/app/src/main/res/layout/item_maui_gmp_a2ui_view.xml
new file mode 100644
index 0000000..18a1a52
--- /dev/null
+++ b/client/android/app/src/main/res/layout/item_maui_gmp_a2ui_view.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/android/app/src/main/res/layout/item_text.xml b/client/android/app/src/main/res/layout/item_text.xml
new file mode 100644
index 0000000..1b2bb61
--- /dev/null
+++ b/client/android/app/src/main/res/layout/item_text.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/client/android/app/src/main/res/mipmap/.gitkeep b/client/android/app/src/main/res/mipmap/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/client/android/app/src/main/res/mipmap/ic_launcher.xml b/client/android/app/src/main/res/mipmap/ic_launcher.xml
new file mode 100644
index 0000000..3105e0a
--- /dev/null
+++ b/client/android/app/src/main/res/mipmap/ic_launcher.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/android/app/src/main/res/mipmap/ic_launcher_round.xml b/client/android/app/src/main/res/mipmap/ic_launcher_round.xml
new file mode 100644
index 0000000..3105e0a
--- /dev/null
+++ b/client/android/app/src/main/res/mipmap/ic_launcher_round.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/android/app/src/main/res/values/colors.xml b/client/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..178d8fd
--- /dev/null
+++ b/client/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,25 @@
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/client/android/app/src/main/res/values/strings.xml b/client/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..102c6fc
--- /dev/null
+++ b/client/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Agentic UI ToolKit
+
diff --git a/client/android/app/src/main/res/values/themes.xml b/client/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..b7c931e
--- /dev/null
+++ b/client/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/client/android/build.gradle b/client/android/build.gradle
new file mode 100644
index 0000000..fb10a56
--- /dev/null
+++ b/client/android/build.gradle
@@ -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.
+//
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '9.0.0' apply false
+ id 'com.android.library' version '9.0.0' apply false
+ id 'org.jetbrains.kotlin.android' version '2.3.10' apply false
+ id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false
+}
diff --git a/client/android/gradle.properties b/client/android/gradle.properties
new file mode 100644
index 0000000..89a18f4
--- /dev/null
+++ b/client/android/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Settings specified here will override any Gradle settings in the IDE.
+
+# For more details on the syntax, see the Gradle documentation.
+# https://docs.gradle.org/current/userguide/gradle_properties_filter.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings for the Gradle daemon.
+# Default value: -Xmx1024m -XX:MaxMetaspaceSize=256m
+org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+
+# When configured, Gradle will run in a separate daemon process.
+# This avoids the overhead of starting a new JVM for each build.
+# org.gradle.daemon=true
+
+# Specifies the build service to use.
+# org.gradle.configureondemand=true
+
+# Specifies the Java home for Gradle.
diff --git a/client/android/gradle/wrapper/gradle-wrapper.jar b/client/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/client/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/client/android/gradle/wrapper/gradle-wrapper.properties b/client/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f78a6
--- /dev/null
+++ b/client/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/client/android/gradlew b/client/android/gradlew
new file mode 100755
index 0000000..ef07e01
--- /dev/null
+++ b/client/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/client/android/gradlew.bat b/client/android/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/client/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/client/android/settings.gradle b/client/android/settings.gradle
new file mode 100644
index 0000000..45e3a7f
--- /dev/null
+++ b/client/android/settings.gradle
@@ -0,0 +1,36 @@
+//
+// 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.
+//
+
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ mavenLocal()
+ google()
+ mavenCentral()
+ }
+}
+
+include ':app'
\ No newline at end of file
diff --git a/client/ios/.gitignore b/client/ios/.gitignore
new file mode 100644
index 0000000..a00659e
--- /dev/null
+++ b/client/ios/.gitignore
@@ -0,0 +1,3 @@
+# Xcode user data
+A2UI-Example.xcodeproj/project.xcworkspace/xcuserdata/
+A2UI-Example.xcodeproj/xcuserdata/
diff --git a/client/ios/A2UI-Example-Info.plist b/client/ios/A2UI-Example-Info.plist
new file mode 100644
index 0000000..6a6654d
--- /dev/null
+++ b/client/ios/A2UI-Example-Info.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+
+
diff --git a/client/ios/A2UI-Example.xcodeproj/project.pbxproj b/client/ios/A2UI-Example.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..ec2e66e
--- /dev/null
+++ b/client/ios/A2UI-Example.xcodeproj/project.pbxproj
@@ -0,0 +1,502 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 06250BF72FC7D25D009091F1 /* GoogleMapsA2UI in Frameworks */ = {isa = PBXBuildFile; productRef = 06250BF62FC7D25D009091F1 /* GoogleMapsA2UI */; };
+ FDC9C4E32F5B9D7000ABA773 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC9C4E22F5B9D7000ABA773 /* Models.swift */; };
+ FDC9C4E42F5B9D7000ABA773 /* ChatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC9C4DF2F5B9D7000ABA773 /* ChatApp.swift */; };
+ FDC9C4E52F5B9D7000ABA773 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC9C4E12F5B9D7000ABA773 /* ChatViewModel.swift */; };
+ FDC9C4E72F5B9D7000ABA773 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC9C4E02F5B9D7000ABA773 /* ChatView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ FD3276E12F7B9B3B007E25E3 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = FDC9C4C82F5B9CB800ABA773 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = FDC9C4CF2F5B9CB800ABA773;
+ remoteInfo = "A2UI-Example";
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ FD3276DB2F7B9B3B007E25E3 /* A2UI-ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "A2UI-ExampleUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ FD8193352F749D4A001C0D09 /* A2UI-Example-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "A2UI-Example-Info.plist"; sourceTree = ""; };
+ FDC9C4D02F5B9CB800ABA773 /* A2UI-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "A2UI-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ FDC9C4DF2F5B9D7000ABA773 /* ChatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatApp.swift; sourceTree = ""; };
+ FDC9C4E02F5B9D7000ABA773 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; };
+ FDC9C4E12F5B9D7000ABA773 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; };
+ FDC9C4E22F5B9D7000ABA773 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ FD3276DC2F7B9B3B007E25E3 /* A2UI-ExampleUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "A2UI-ExampleUITests";
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ FD3276D82F7B9B3B007E25E3 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ FDC9C4CD2F5B9CB800ABA773 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 06250BF72FC7D25D009091F1 /* GoogleMapsA2UI in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ FDC9C4C72F5B9CB800ABA773 = {
+ isa = PBXGroup;
+ children = (
+ FD8193352F749D4A001C0D09 /* A2UI-Example-Info.plist */,
+ FDC9C4E82F5B9DE300ABA773 /* Resources */,
+ FDC9C4DF2F5B9D7000ABA773 /* ChatApp.swift */,
+ FDC9C4E02F5B9D7000ABA773 /* ChatView.swift */,
+ FDC9C4E12F5B9D7000ABA773 /* ChatViewModel.swift */,
+ FDC9C4E22F5B9D7000ABA773 /* Models.swift */,
+ FD3276DC2F7B9B3B007E25E3 /* A2UI-ExampleUITests */,
+ FDC9C4D12F5B9CB800ABA773 /* Products */,
+ );
+ sourceTree = "";
+ };
+ FDC9C4D12F5B9CB800ABA773 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ FDC9C4D02F5B9CB800ABA773 /* A2UI-Example.app */,
+ FD3276DB2F7B9B3B007E25E3 /* A2UI-ExampleUITests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ FDC9C4E82F5B9DE300ABA773 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ FD3276DA2F7B9B3B007E25E3 /* A2UI-ExampleUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = FD3276E52F7B9B3B007E25E3 /* Build configuration list for PBXNativeTarget "A2UI-ExampleUITests" */;
+ buildPhases = (
+ FD3276D72F7B9B3B007E25E3 /* Sources */,
+ FD3276D82F7B9B3B007E25E3 /* Frameworks */,
+ FD3276D92F7B9B3B007E25E3 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ FD3276E22F7B9B3B007E25E3 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ FD3276DC2F7B9B3B007E25E3 /* A2UI-ExampleUITests */,
+ );
+ name = "A2UI-ExampleUITests";
+ packageProductDependencies = (
+ );
+ productName = "A2UI-ExampleUITests";
+ productReference = FD3276DB2F7B9B3B007E25E3 /* A2UI-ExampleUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
+ FDC9C4CF2F5B9CB800ABA773 /* A2UI-Example */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = FDC9C4DB2F5B9CBB00ABA773 /* Build configuration list for PBXNativeTarget "A2UI-Example" */;
+ buildPhases = (
+ FDC9C4CC2F5B9CB800ABA773 /* Sources */,
+ FDC9C4CD2F5B9CB800ABA773 /* Frameworks */,
+ FDC9C4CE2F5B9CB800ABA773 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = "A2UI-Example";
+ packageProductDependencies = (
+ 06250BF62FC7D25D009091F1 /* GoogleMapsA2UI */,
+ );
+ productName = "A2UI-Example";
+ productReference = FDC9C4D02F5B9CB800ABA773 /* A2UI-Example.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ FDC9C4C82F5B9CB800ABA773 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2600;
+ LastUpgradeCheck = 2600;
+ TargetAttributes = {
+ FD3276DA2F7B9B3B007E25E3 = {
+ CreatedOnToolsVersion = 26.0.1;
+ TestTargetID = FDC9C4CF2F5B9CB800ABA773;
+ };
+ FDC9C4CF2F5B9CB800ABA773 = {
+ CreatedOnToolsVersion = 26.0.1;
+ LastSwiftMigration = 2600;
+ };
+ };
+ };
+ buildConfigurationList = FDC9C4CB2F5B9CB800ABA773 /* Build configuration list for PBXProject "A2UI-Example" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = FDC9C4C72F5B9CB800ABA773;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ 06250BF52FC7D25D009091F1 /* XCLocalSwiftPackageReference "../../../a2ui/client/ios/GoogleMapsA2UI" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = FDC9C4D12F5B9CB800ABA773 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ FDC9C4CF2F5B9CB800ABA773 /* A2UI-Example */,
+ FD3276DA2F7B9B3B007E25E3 /* A2UI-ExampleUITests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ FD3276D92F7B9B3B007E25E3 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ FDC9C4CE2F5B9CB800ABA773 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ FD3276D72F7B9B3B007E25E3 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ FDC9C4CC2F5B9CB800ABA773 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ FDC9C4E32F5B9D7000ABA773 /* Models.swift in Sources */,
+ FDC9C4E42F5B9D7000ABA773 /* ChatApp.swift in Sources */,
+ FDC9C4E52F5B9D7000ABA773 /* ChatViewModel.swift in Sources */,
+ FDC9C4E72F5B9D7000ABA773 /* ChatView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ FD3276E22F7B9B3B007E25E3 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = FDC9C4CF2F5B9CB800ABA773 /* A2UI-Example */;
+ targetProxy = FD3276E12F7B9B3B007E25E3 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ FD3276E32F7B9B3B007E25E3 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.google.A2UI-ExampleUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = "A2UI-Example";
+ };
+ name = Debug;
+ };
+ FD3276E42F7B9B3B007E25E3 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.google.A2UI-ExampleUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = "A2UI-Example";
+ };
+ name = Release;
+ };
+ FDC9C4D92F5B9CBB00ABA773 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ FDC9C4DA2F5B9CBB00ABA773 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ FDC9C4DC2F5B9CBB00ABA773 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "A2UI-Example-Info.plist";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.google.A2UI-Example";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ FDC9C4DD2F5B9CBB00ABA773 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "A2UI-Example-Info.plist";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.google.A2UI-Example";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ FD3276E52F7B9B3B007E25E3 /* Build configuration list for PBXNativeTarget "A2UI-ExampleUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ FD3276E32F7B9B3B007E25E3 /* Debug */,
+ FD3276E42F7B9B3B007E25E3 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ FDC9C4CB2F5B9CB800ABA773 /* Build configuration list for PBXProject "A2UI-Example" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ FDC9C4D92F5B9CBB00ABA773 /* Debug */,
+ FDC9C4DA2F5B9CBB00ABA773 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ FDC9C4DB2F5B9CBB00ABA773 /* Build configuration list for PBXNativeTarget "A2UI-Example" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ FDC9C4DC2F5B9CBB00ABA773 /* Debug */,
+ FDC9C4DD2F5B9CBB00ABA773 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 06250BF52FC7D25D009091F1 /* XCLocalSwiftPackageReference "../../../a2ui/client/ios/GoogleMapsA2UI" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../../../a2ui/client/ios/GoogleMapsA2UI;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 06250BF62FC7D25D009091F1 /* GoogleMapsA2UI */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 06250BF52FC7D25D009091F1 /* XCLocalSwiftPackageReference "../../../a2ui/client/ios/GoogleMapsA2UI" */;
+ productName = GoogleMapsA2UI;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = FDC9C4C82F5B9CB800ABA773 /* Project object */;
+}
diff --git a/client/ios/A2UI-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/client/ios/A2UI-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/client/ios/A2UI-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift b/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift
new file mode 100644
index 0000000..ba81d92
--- /dev/null
+++ b/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift
@@ -0,0 +1,107 @@
+//
+// 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.
+//
+
+import XCTest
+
+final class A2UIExampleUITests: XCTestCase {
+
+ let app = XCUIApplication()
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ // The application is relaunched for each testcase. This precludes us from having to clean
+ // up application state each time.
+ app.launch()
+ }
+
+ func testSeattleCoffeeShops() throws {
+ try runTestCase(named: "Seattle Coffee Shops")
+ }
+
+ func testMVGoogleGyms() throws {
+ try runTestCase(named: "MV Google Gyms")
+ }
+
+ func testEdgewaterHotel() throws {
+ try runTestCase(named: "Edgewater Hotel")
+ }
+
+ func testGasWorksPark() throws {
+ try runTestCase(named: "Gas Works Park")
+ }
+
+ func testKirklandCommute() throws {
+ try runTestCase(named: "Kirkland Commute")
+ }
+
+ func testLePetiteAcademy() throws {
+ try runTestCase(named: "Le Petite Academy")
+ }
+
+ func testNYCAttractions() throws {
+ try runTestCase(named: "NYC Attractions")
+ }
+
+ func testSLUSaladsVegan() throws {
+ try runTestCase(named: "SLU Salads (Vegan)")
+ }
+
+ func testSLUSaladsClick() throws {
+ try runTestCase(named: "SLU Salads (Click)")
+ }
+
+ func testSLUSaladsDirections() throws {
+ try runTestCase(named: "SLU Salads (Directions)")
+ }
+
+ func testLondonItinerary() throws {
+ try runTestCase(named: "London Itinerary")
+ }
+
+ // MARK: - Helper Methods
+
+ /// Runs a specified UI test case by simulating user interaction.
+ ///
+ /// - Parameter testCaseName: The name of the test case to run, matching the button label.
+ /// - Throws: An error if an expectation times out or fails.
+ private func runTestCase(named testCaseName: String) throws {
+ // Tap the flask icon to open the TestCases menu
+ app.buttons["flask.fill"].tap()
+
+ // Tap the specific test case
+ app.buttons[testCaseName].firstMatch.tap()
+
+ // Tap the send button (paperplane.fill)
+ app.buttons["paperplane.fill"].tap()
+
+ // Wait for the web view to appear, indicating an A2UI response
+ let webView = app.webViews.element
+ let exists = NSPredicate(format: "exists == true")
+ expectation(for: exists, evaluatedWith: webView, handler: nil)
+
+ // Use a longer timeout as agent responses can take time
+ waitForExpectations(timeout: 30, handler: nil)
+
+ XCTAssertTrue(webView.exists, "Web view should exist for test case: \(testCaseName)")
+
+ // Ensure the web view contains some text content
+ let webViewHasContent = NSPredicate(format: "staticTexts.count > 0")
+ expectation(for: webViewHasContent, evaluatedWith: webView, handler: nil)
+ waitForExpectations(timeout: 10, handler: nil)
+
+ XCTAssertGreaterThan(webView.staticTexts.count, 0, "Web view should contain text content for test case: \(testCaseName)")
+ }
+}
diff --git a/client/ios/ChatApp.swift b/client/ios/ChatApp.swift
new file mode 100644
index 0000000..909a7c9
--- /dev/null
+++ b/client/ios/ChatApp.swift
@@ -0,0 +1,26 @@
+//
+// 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.
+//
+
+import SwiftUI
+
+@main
+struct ChatApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ChatView()
+ }
+ }
+}
diff --git a/client/ios/ChatView.swift b/client/ios/ChatView.swift
new file mode 100644
index 0000000..5c03cab
--- /dev/null
+++ b/client/ios/ChatView.swift
@@ -0,0 +1,331 @@
+//
+// 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.
+//
+
+import SwiftUI
+
+import GoogleMapsA2UI
+
+struct ChatView: View {
+ private enum Constants {
+ static let scrollDelay: TimeInterval = 0.1
+ static let shortAnimationDuration: Double = 0.3
+ static let defaultAnimationDuration: Double = 0.5
+ }
+
+ @StateObject private var viewModel = ChatViewModel()
+ // The text in the input bar.
+ @State private var inputText: String = ""
+ // The last query submitted to the view model. We need this to be able to revert the input text
+ // back to the last query when the user taps on the redo button.
+ @State private var lastQuery: String = ""
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // The Chat History
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(spacing: 12) {
+ ForEach(viewModel.messages) { message in
+ MessageRow(message: message)
+ .id(message.id)
+ }
+
+ if viewModel.isLoading {
+ LoadingBubble()
+ .id("loading-indicator")
+ }
+
+ // A marker at the very bottom to ensure we can always scroll to the end smoothly.
+ Color.clear
+ .frame(height: 1)
+ .id("bottom-marker")
+ }
+ .padding(.horizontal)
+ .padding(.bottom, 10)
+ }
+ .onChange(of: viewModel.messages.count) {
+ if let lastMessage = viewModel.messages.last {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Constants.scrollDelay) {
+ switch lastMessage.kind {
+ case .text(_, let isUser):
+ if isUser {
+ scrollToBottom(proxy: proxy)
+ } else {
+ // For agent text, scroll to its top so it's visible
+ withAnimation(.easeOut(duration: Constants.shortAnimationDuration)) {
+ proxy.scrollTo(lastMessage.id, anchor: .top)
+ }
+ }
+ case .a2uiView:
+ break
+ }
+ }
+ }
+ }
+ .onChange(of: viewModel.webViewToScrollID) { _, id in
+ if let id = id {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Constants.scrollDelay) {
+ withAnimation(.easeInOut(duration: Constants.defaultAnimationDuration)) {
+ proxy.scrollTo(id, anchor: .top)
+ }
+ }
+ }
+ }
+ }
+
+ GroundingSelector(selection: $viewModel.selectedGroundingType)
+
+ Divider()
+
+ // The Input Bar
+ HStack {
+ Menu {
+ Section("TestCases") {
+ Button("Seattle Coffee Shops") {
+ inputText = "Show me 5 coffee shops near South Lake Union in Seattle"
+ }
+ Button("MV Google Gyms") {
+ inputText = "Show me Google Office Buildings in the Mountain View area which have a gym"
+ }
+ Button("Edgewater Hotel") {
+ inputText = "Is the Edgewater Hotel in Seattle a good hotel?"
+ }
+ Button("Gas Works Park") {
+ inputText = "How do I get to Gas Works Park in Fremont from my location (the Edgewater Hotel in Seattle)?"
+ }
+ Button("Kirkland Commute") {
+ inputText = "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?"
+ }
+ Button("Le Petite Academy") {
+ inputText = "How long will it take to go to Le Petite Academy of Kirkland and the Google Kirkland office starting from downtown Redmond during my morning rush hour commute?"
+ }
+ Button("NYC Attractions") {
+ inputText = "How far away are the top 5 major NYC tourist attractions from the Waldorf Astoria New York hotel? Show me all the routes to each of these locations from the Waldorf Astoria Hotel in New York."
+ }
+ Button("SLU Salads (Vegan)") {
+ inputText = "Show me 5 lunch restaurants with Salads in South Lake Union. Which ones of these have vegan friendly options?"
+ }
+ Button("SLU Salads (Click)") {
+ inputText = "Show me 5 lunch restaurants with Salads in South Lake Union. (Inject 'click' to get directions on the 2nd option)"
+ }
+ Button("SLU Salads (Directions)") {
+ inputText = "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)"
+ }
+ Button("London Itinerary") {
+ inputText = "Give me a 3 day itinerary for a family of 3 traveling to London"
+ }
+ }
+ } label: {
+ Image(systemName: "flask.fill")
+ .font(.title2)
+ .foregroundColor(.orange)
+ }
+
+ Menu {
+ Section("Restaurant Finder") {
+ Button("Seattle Indian") {
+ inputText = "Show 3 Indian Restaurants in Seattle"
+ }
+ Button("NYC Chinese") {
+ inputText = "Show top Chinese restaurants in New York"
+ }
+ Button("San Jose Ethiopian") {
+ inputText = "Show 2 Ethiopian Restaurants in San Jose, CA"
+ }
+ Button("Seattle Sushi") {
+ inputText = "Show me some good sushi in Seattle"
+ }
+ }
+ Section("Place Details") {
+ Button("Parking at Milstead") {
+ inputText = "Is there parking near Milstead Coffee?"
+ }
+ Button("Vegan at Pablo y Pablo") {
+ inputText = "Are there vegan options at Pablo y Pablo?"
+ }
+ }
+ Section("Routes") {
+ Button("Seattle to LA") {
+ inputText = "I am going from Seattle to LA by car. I want to make stops to get food and rest. Can you show me route options, including stop points? I am also sensitive to air quality, if you could tell me the forecast along the route, thanks!"
+ }
+ Button("Vegetarian House to Din Tai Fung") {
+ inputText = "How to get from Vegetarian House to Din Tai Fung in San Jose CA"
+ }
+ Button("Directions to Hadilao") {
+ inputText = "Get me directions to Hadilao Hot Pot Cupertino"
+ }
+ }
+ Section("Location Analysis") {
+ Button("New Home Location") {
+ inputText = "I'm considering buying a new home at <2200 N 56th St, Seattle, WA 98103> Do you think this a good location to get to my work at Google Fremont in Seattle? Can I easily get my morning latte at Milstead on my way to work? Are there any public tennis courts nearby? Most importantly, am I close enough to a Din Tai Fung for sunday dinner?"
+ }
+ }
+ } label: {
+ Image(systemName: "list.bullet.circle.fill")
+ .font(.title2)
+ .foregroundColor(.blue)
+ }
+
+ TextField("Message...", text: $inputText, axis: .vertical)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .lineLimit(1...8)
+ .foregroundColor(.primary)
+ .padding(.vertical, 8)
+ .onSubmit { // Submit when the user presses the return key
+ submitMessage()
+ }
+
+ if !lastQuery.isEmpty && inputText.isEmpty {
+ Button(action: {
+ inputText = lastQuery
+ }) {
+ Image(systemName: "arrow.uturn.backward")
+ .foregroundColor(.white)
+ .padding(10)
+ .background(Color.gray)
+ .clipShape(Circle())
+ }
+ }
+
+ Button(action: submitMessage) {
+ Image(systemName: "paperplane.fill")
+ .foregroundColor(.white)
+ .padding(10)
+ .background(Color.blue)
+ .clipShape(Circle())
+ }
+ }
+ .padding(.horizontal)
+ .padding(.bottom, 8)
+ }
+ }
+
+ /// Submits the current input text to the view model.
+ private func submitMessage() {
+ let text = inputText.trimmingCharacters(in: .whitespaces)
+ guard !text.isEmpty else { return }
+ viewModel.sendMessage(text: text)
+ lastQuery = text
+ inputText = ""
+ }
+
+ /// Smoothly scrolls the list to the very bottom marker.
+ ///
+ /// - Parameter proxy: The `ScrollViewProxy` used to control scroll positioning.
+ private func scrollToBottom(proxy: ScrollViewProxy) {
+ withAnimation(.easeOut(duration: Constants.shortAnimationDuration)) {
+ proxy.scrollTo("bottom-marker", anchor: .bottom)
+ }
+ }
+}
+
+// Layout for individual chat bubbles
+struct MessageRow: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack {
+ switch message.kind {
+ case .text(let content, let isUser):
+ if isUser { Spacer() }
+
+ Text(content)
+ .padding(12)
+ .background(isUser ? Color.blue : Color(UIColor.systemGray5))
+ .foregroundColor(isUser ? .white : .primary)
+ .clipShape(ChatBubbleShape(isUser: isUser))
+
+ if !isUser { Spacer() }
+
+ case .a2uiView(_, let view):
+ view
+ }
+ }
+ }
+}
+
+// Custom shape for chat bubble corners
+struct ChatBubbleShape: Shape {
+ let isUser: Bool
+
+ /// Calculates the rounded corner path based on the message sender.
+ ///
+ /// - Parameter rect: The bounding rectangle of the bubble.
+ /// - Returns: A `Path` describing the custom shape.
+ func path(in rect: CGRect) -> Path {
+ let path = UIBezierPath(
+ roundedRect: rect,
+ byRoundingCorners: [
+ .topLeft,
+ .topRight,
+ isUser ? .bottomLeft : .bottomRight, // Flat corner on the sender's side
+ ],
+ cornerRadii: CGSize(width: 16, height: 16)
+ )
+ return Path(path.cgPath)
+ }
+}
+
+struct LoadingBubble: View {
+ var body: some View {
+ HStack {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ Text("Agent is thinking...")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ .padding(12)
+ .background(Color(UIColor.systemGray6))
+ .clipShape(ChatBubbleShape(isUser: false))
+
+ Spacer()
+ }
+ }
+}
+
+struct GroundingSelector: View {
+ @Binding var selection: GroundingType
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(GroundingType.allCases) { type in
+ Button(action: {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
+ selection = type
+ }
+ }) {
+ HStack(spacing: 12) {
+ Image(systemName: selection == type ? "dot.circle.fill" : "circle")
+ .foregroundColor(selection == type ? .blue : .secondary)
+ .font(.title3)
+
+ Text(type.rawValue)
+ .font(.body)
+ .foregroundColor(.primary)
+
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ }
+}
diff --git a/client/ios/ChatViewModel.swift b/client/ios/ChatViewModel.swift
new file mode 100644
index 0000000..1e4d635
--- /dev/null
+++ b/client/ios/ChatViewModel.swift
@@ -0,0 +1,336 @@
+//
+// 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.
+//
+
+import Combine
+import Foundation
+import SwiftUI
+
+import GoogleMapsA2UI
+
+class ChatViewModel: ObservableObject {
+ @Published var messages: [ChatMessage] = []
+ @Published var isLoading: Bool = false
+ @Published var webViewToScrollID: UUID?
+ @Published var selectedGroundingType: GroundingType = .lite
+ private let googleMapsApiKey = "YOUR_API_KEY"
+
+ init() {
+ A2UIServices.provideApiKey(googleMapsApiKey)
+ }
+
+
+ enum ServerType {
+ case demo // Port 10002 (Internal)
+ case remote // Remote Gateway
+ }
+
+ // --- CONFIGURATION ---
+ private let activeServer: ServerType = .remote
+ private let remoteEndpoint = "REQUIRED_REMOTE_ENDPOINT"
+ private let apiKey = "REQUIRED_REMOTE_API_KEY"
+ // ---------------------
+
+ private var baseUrl: String {
+ switch activeServer {
+ case .demo: return "http://localhost:10002"
+ case .remote: return remoteEndpoint
+ }
+ }
+
+ private var appName: String {
+ switch activeServer {
+ case .demo: return "restaurant_finder"
+ case .remote: return "restaurant_finder"
+ }
+ }
+
+ private var activeSessionID: String?
+ private var useSSEProtocol = false
+ private let contextID = UUID().uuidString
+
+ /// Sends a text message to the server.
+ ///
+ /// - Parameter text: The message string to send.
+ func sendMessage(text: String) {
+ var serverText = text
+ if selectedGroundingType == .vertex {
+ serverText = "[GROUNDING] \(text)"
+ }
+ let payload: [String: Any] = ["text": serverText]
+ addMessage(.text(content: text, isUser: true))
+ callPythonServer(userMessage: payload)
+ }
+
+ /// Sends a structured action (like 'get_directions') to the server.
+ ///
+ /// - Parameter jsonString: A JSON string containing the action context.
+ func sendAction(jsonString: String) {
+
+ guard let data = jsonString.data(using: .utf8),
+ let contextDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ print("Failed to parse action JSON")
+ return
+ }
+
+ let payload: [String: Any] = [
+ "userAction": [
+ "name": "get_directions",
+ "context": contextDict,
+ ]
+ ]
+ callPythonServer(userMessage: payload)
+ }
+
+ /// Discovers the underlying protocol (SSE vs JSON-RPC) required to communicate with the server.
+ ///
+ /// - Parameter completion: A closure called once the protocol discovery is complete.
+ private func discoverProtocol(completion: @escaping () -> Void) {
+ // If we already have a session, we've already discovered the protocol
+ if activeSessionID != nil || !useSSEProtocol && activeSessionID != nil {
+ completion()
+ return
+ }
+
+ // Attempt to create a session (ADK Web Server Handshake)
+ var urlString = "\(baseUrl)/apps/\(appName)/users/user/sessions"
+ if activeServer == .remote {
+ urlString += "?key=\(apiKey)"
+ }
+ guard let url = URL(string: urlString) else {
+ completion()
+ return
+ }
+ var request = URLRequest(url: url)
+ request.timeoutInterval = 120
+ request.httpMethod = "POST"
+
+ if activeServer == .remote {
+ request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
+ }
+
+ URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
+ let httpResponse = response as? HTTPURLResponse
+
+ if let http = httpResponse {
+ print("DEBUG: Handshake HTTP Status: \(http.statusCode)")
+ if let data = data, let body = String(data: data, encoding: .utf8) {
+ print("DEBUG: Handshake Response Body: \(body)")
+ }
+ }
+
+ if let error = error {
+ print("DEBUG: Handshake Error: \(error.localizedDescription)")
+ }
+
+ if httpResponse?.statusCode == 200, let data = data,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let id = json["id"] as? String
+ {
+ DispatchQueue.main.async {
+ self?.activeSessionID = id
+ self?.useSSEProtocol = true
+ print("DEBUG: Discovered ADK Web Server (SSE) protocol")
+ completion()
+ }
+ } else {
+ DispatchQueue.main.async {
+ self?.useSSEProtocol = false
+ print("DEBUG: Discovered Standalone (JSON-RPC) protocol")
+ completion()
+ }
+ }
+ }.resume()
+ }
+
+ /// Calls the Python backend server with the provided user message payload.
+ ///
+ /// - Parameter userMessage: A dictionary containing the message or action to send.
+ private func callPythonServer(userMessage: [String: Any]) {
+ isLoading = true
+
+ discoverProtocol { [weak self] in
+ guard let self = self else { return }
+
+ var parts: [[String: Any]] = []
+ if let text = userMessage["text"] as? String {
+ parts.append(["text": text])
+ } else if let action = userMessage["userAction"] as? [String: Any] {
+ parts.append(["data": ["userAction": action]])
+ }
+
+ var request: URLRequest
+ if self.useSSEProtocol {
+ // SSE Protocol for ADK Web Server
+ let body: [String: Any] = [
+ "appName": self.appName,
+ "userId": "user",
+ "sessionId": self.activeSessionID ?? "",
+ "newMessage": [
+ "role": "user",
+ "parts": parts,
+ ],
+ ]
+ var urlString = "\(self.baseUrl)/run_sse"
+ if self.activeServer == .remote {
+ urlString += "?key=\(self.apiKey)"
+ }
+ guard let url = URL(string: urlString) else { return }
+ request = URLRequest(url: url)
+ request.timeoutInterval = 120
+ request.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ } else {
+ // JSON-RPC Protocol for Standalone Server
+ let body: [String: Any] = [
+ "jsonrpc": "2.0",
+ "method": "message/send",
+ "id": 1,
+ "params": [
+ "message": [
+ "role": "user",
+ "messageId": UUID().uuidString,
+ "contextId": self.contextID,
+ "parts": parts,
+ ]
+ ],
+ ]
+ var urlString = self.baseUrl
+ if self.activeServer == .remote {
+ urlString += "?key=\(self.apiKey)"
+ }
+ guard let url = URL(string: urlString) else { return }
+ request = URLRequest(url: url)
+ request.timeoutInterval = 120
+ request.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ }
+
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ if self.activeServer == .remote {
+ request.setValue(self.apiKey, forHTTPHeaderField: "x-api-key")
+ }
+
+ if self.activeServer == .demo {
+ request.setValue(
+ "https://a2ui.org/a2a-extension/a2ui/v0.9", forHTTPHeaderField: "X-A2A-Extensions")
+ }
+
+ URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
+ DispatchQueue.main.async {
+ guard let self = self else { return }
+ self.isLoading = false
+
+ if let httpResponse = response as? HTTPURLResponse {
+ print("DEBUG: HTTP Status Code: \(httpResponse.statusCode)")
+ print("DEBUG: Response Headers: \(httpResponse.allHeaderFields)")
+ }
+
+ guard let data = data, error == nil else {
+ print("DEBUG: Network Error: \(error?.localizedDescription ?? "Unknown")")
+ self.addMessage(.text(content: "Network Error: \(error?.localizedDescription ?? "Unknown")", isUser: false))
+ return
+ }
+
+ let responseString = String(data: data, encoding: .utf8) ?? ""
+ print("DEBUG: Raw Server Response: \(responseString)")
+
+
+ if self.useSSEProtocol || responseString.contains("data: ") {
+ // Handle SSE Stream
+ let events = responseString.components(separatedBy: "\n")
+ .filter { $0.hasPrefix("data: ") }
+ .map { $0.replacingOccurrences(of: "data: ", with: "") }
+
+ for event in events {
+ if let eventData = event.data(using: .utf8),
+ let rawJson = try? JSONSerialization.jsonObject(with: eventData) as? [String: Any] {
+ self.processJsonResponse(rawJson)
+ }
+ }
+ } else {
+ // Handle Single JSON response
+ if let rawJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ if let result = rawJson["result"] as? [String: Any] {
+ self.processJsonResponse(result)
+ } else {
+ self.processJsonResponse(rawJson)
+ }
+ }
+ }
+ }
+ }.resume()
+ }
+ }
+
+ /// Processes a single JSON response from the server, adding messages to the timeline.
+ ///
+ /// - Parameter json: The parsed JSON dictionary from the server.
+ private func processJsonResponse(_ json: [String: Any]) {
+ if let errorDict = json["error"] as? [String: Any],
+ let errorMessage = errorDict["message"] as? String {
+ self.addMessage(.text(content: "Server Error: \(errorMessage)", isUser: false))
+ return
+ } else if let errorObj = json["error"] {
+ let errorString = (errorObj is NSNull) ? "Unknown error" : String(describing: errorObj)
+ self.addMessage(.text(content: "Server Error: \(errorString)", isUser: false))
+ return
+ }
+
+ do {
+ let parts = try A2AResponseParser.parse(json)
+ for part in parts {
+ switch part {
+ case .data(_, let metadata):
+ if metadata?.mimeType == "application/json+a2ui" {
+ let messageId = UUID()
+ let view = A2UIView(
+ part: part,
+ id: messageId.uuidString,
+ onUserAction: { [weak self] actionStr in
+ self?.sendAction(jsonString: actionStr)
+ },
+ onRenderComplete: { [weak self] id, latency, status in
+ if let uuid = UUID(uuidString: id) {
+ DispatchQueue.main.async {
+ self?.webViewToScrollID = uuid
+ }
+ }
+ }
+ )
+ let message = ChatMessage(id: messageId, kind: .a2uiView(type: "GoogleMapsA2UI", view: AnyView(view)))
+ self.addMessage(message)
+ }
+ case .text(let text):
+ if !text.isEmpty {
+ self.addMessage(.text(content: text, isUser: false))
+ }
+ }
+ }
+ } catch {
+ print("Error parsing response: \(error)")
+ self.addMessage(.text(content: "Parsing Error: \(error.localizedDescription)", isUser: false))
+ }
+ }
+
+
+ /// Adds a message to the local chat message list.
+ ///
+ /// - Parameter msg: The `ChatMessage` to append to the conversation.
+ private func addMessage(_ msg: ChatMessage) {
+ self.messages.append(msg)
+ }
+}
diff --git a/client/ios/Info.plist b/client/ios/Info.plist
new file mode 100644
index 0000000..68eb763
--- /dev/null
+++ b/client/ios/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/client/ios/Models.swift b/client/ios/Models.swift
new file mode 100644
index 0000000..1c37dee
--- /dev/null
+++ b/client/ios/Models.swift
@@ -0,0 +1,58 @@
+//
+// 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.
+//
+
+import Foundation
+import SwiftUI
+
+enum GroundingType: String, CaseIterable, Identifiable {
+ case lite = "Grounding Lite (MCP)"
+ case vertex = "Grounding with Google Maps (Vertex)"
+
+ var id: Self { self }
+}
+
+struct ChatMessage: Identifiable {
+ var id = UUID()
+
+ enum Kind {
+ case text(content: String, isUser: Bool)
+ case a2uiView(type: String, view: AnyView)
+ }
+
+ var kind: Kind
+
+ /// Creates a text-based chat message.
+ ///
+ /// - Parameters:
+ /// - content: The text content of the message.
+ /// - isUser: A boolean indicating whether the message is from the user (`true`) or the agent (`false`).
+ /// - Returns: A new `ChatMessage` instance.
+ static func text(content: String, isUser: Bool) -> ChatMessage {
+ return ChatMessage(kind: .text(content: content, isUser: isUser))
+ }
+
+ /// Creates an A2UI view-based chat message.
+ ///
+ /// - Parameters:
+ /// - type: A string identifier for the A2UI type.
+ /// - view: The underlying SwiftUI `AnyView` representing the rendered A2UI.
+ /// - Returns: A new `ChatMessage` instance.
+ static func a2uiView(type: String, _ view: AnyView) -> ChatMessage {
+ return ChatMessage(kind: .a2uiView(type: type, view: view))
+ }
+}
+
+
diff --git a/client/ios/README.md b/client/ios/README.md
new file mode 100644
index 0000000..bb83d92
--- /dev/null
+++ b/client/ios/README.md
@@ -0,0 +1,103 @@
+# Maps Agentic UI Toolkit iOS Demo App
+
+This is an example iOS application demonstrating the Maps Agentic UI Toolkit. It leverages the [`GoogleMapsA2UI`](https://github.com/googlemaps/a2ui/tree/main/client/ios) module to parse backend A2A payloads and render A2UI messages natively.
+
+## Library Dependency
+
+This application relies on the core `GoogleMapsA2UI` module. To set up this dependency for the sample app, add it as a local Swift Package:
+
+1. Open the sample project in Xcode.
+2. Follow the [How to Integrate] steps from the [`GoogleMapsA2UI` README](https://github.com/googlemaps/a2ui/tree/main/client/ios/README.md).
+ * **Note:** When prompted for the package location, use the local path to the `a2ui/client/ios/GoogleMapsA2UI` folder.
+
+## Project Structure
+
+* `ChatApp.swift`: The main application entry point.
+* `ChatView.swift`: The main chat UI, displaying message history, the input bar, and toggles to switch between data grounding modes (e.g., Vertex AI Maps Grounding vs. MCP Lite).
+* `ChatViewModel.swift`: Handles all networking with the backend protocols, maintains state, and routes A2A responses to the `GoogleMapsA2UI` library parser.
+* `Models.swift`: Basic data structures for chat messages and grounding mode configurations.
+* `GoogleMapsA2UI`: A Swift package dependency pulled in from the `a2ui` module. It provides the `A2UIView` SwiftUI component to render the dynamic maps components and parses the A2A payload into a list of `ParsedA2AEvent` objects. *(See the [Library Dependency](#library-dependency) section above for integration details).*
+
+## Quickstart Guide
+
+### 1. Set API Keys and Gateway URL
+
+Before running the application, you must configure your API keys and endpoints in `ChatViewModel.swift`.
+
+1. Open `ChatViewModel.swift`.
+2. Locate the following variables at the top of the file and replace them with your actual values:
+ ```swift
+ private let googleMapsApiKey = "YOUR_API_KEY"
+ // --- CONFIGURATION ---
+ private let activeServer: ServerType = .remote
+ private let remoteEndpoint = "REQUIRED_REMOTE_ENDPOINT"
+ private let apiKey = "REQUIRED_REMOTE_API_KEY"
+ // ---------------------
+ ```
+
+* You can create a Google Maps API Key in the [Google Cloud Console](https://mapsplatform.google.com/).
+
+### 2. Connectivity Options
+
+The app is configured to connect to two types of servers by setting the `activeServer` property in `ChatViewModel.swift`:
+
+1. **`.demo` (Local Demo Server):** The default A2UI server provided in this repository, usually running on `http://localhost:10002`.
+2. **`.remote` (Remote Gateway):** Connects to the cloud-hosted agent gateway using your provided `remoteEndpoint` and `apiKey`.
+
+### 3. Build and Run
+
+Open the project in Xcode (or use your preferred build system) and run the app.
+
+**Connecting to a Local Server (Simulator vs. Physical Device):**
+If your `activeServer` is set to `.demo` (This means the server is running on your Mac):
+* **Simulator:** You can leave the URL in `baseUrl` as `http://localhost:10002` (or `127.0.0.1`).
+* **Physical Device:** `localhost` resolves to the iPhone itself, not your Mac. You must find your Mac's Wi-Fi IP address (e.g., run `ipconfig getifaddr en0`). Then, in `ChatViewModel.swift`, update the string returned by `baseUrl` to use this IP (e.g., change `"http://localhost:10002"` to `"http://192.168.68.93:10002"`).
+* **Binding to `0.0.0.0`:** By default, your Mac's server will block connections from outside devices. To allow your physical iPhone to connect, you must start your python server with the `--host 0.0.0.0` flag (e.g., `python server.py --host 0.0.0.0 --port 10002`). This tells the server to listen to the Wi-Fi network instead of just `localhost`.
+
+*(Note: If you are using `.remote`, you do not need to change IPs or host bindings since the gateway is cloud-hosted).*
+
+### 4. Using the Demo
+
+Once the app is running:
+* **Select Grounding Mode:** Use the radio buttons above the chat bar to toggle between **Grounding Lite (MCP)** and **Grounding with Google Maps (Vertex)**.
+* **Use Canned Prompts:** Tap the **Flask** or **List** icons next to the text input for a menu of pre-written test scenarios.
+* **Send Custom Prompts:** Type a query into the text box (e.g., *"Show me 3 Chinese restaurants in Seattle"*) and hit send.
+* **Interact with Maps:** Wait for the A2UI components to load. You can interact with the rendered maps and place cards (like tapping `Get Directions`) to trigger native Swift callbacks.
+
+## Running UI Tests
+
+We provide an automated script to spin up a local backend server and execute the Xcode UI tests using the iOS Simulator.
+
+To run the full test suite from the terminal:
+```bash
+cd /client/ios
+./run_xcode_ui_tests.sh
+```
+> **Note:** The test script automatically starts the backend server by looking for `run_aikit_demo.sh` at `../../../a2ui`. If you cloned the `a2ui` repository to a different location, you must open `run_xcode_ui_tests.sh` and update that path before running the tests.
+
+**Note:** This script requires the `iPhone 17 Pro` simulator to be installed on your system. It automatically launches the backend server and safely shuts it down once the UI tests finish. Additionally, ensure the `GoogleMapsA2UI` Swift Package has been successfully compiled in Xcode before running this script.
+
+## Customizing the Web Components
+
+The `A2UIView` component from the `GoogleMapsA2UI` library is a native wrapper around a `WKWebView`. It does not draw the actual map cards using Swift. Instead, it loads a local `index.html` file that is built from the React web application using lit renderer located in `client/web/react`.
+
+**What can you customize?**
+By modifying the React web application located at `client/web/react` (specifically `src/AppMobile.tsx`), you can alter the A2UIView's visual specifications. For example:
+* **Styling & Layout:** Change background colors, sizes, fonts, or padding of the rendering surface.
+* **Component Behavior:** Inject CSS transforms or resizing logic (e.g., our existing hack that forces `` to render image thumbnails even inside narrow iOS chat bubbles).
+* **Native Bridge Integration:** Add or modify JavaScript callbacks that communicate with the native Swift layer.
+
+**Why do you need to rebuild `index.html`?**
+Because the iOS `GoogleMapsA2UI` library relies entirely on the local `index.html` bundle to define its visual rendering spec, any changes you make in the React codebase must be re-compiled into a new, minified bundle and copied into the iOS project.
+
+To update the iOS app with your web customizations:
+
+1. Navigate to the web project and run the mobile build:
+ ```bash
+ cd client/web/react
+ npm install
+ # This creates the mobile-specific index.html bundle
+ npm run build:mobile
+ ```
+
+2. Copy the `dist/index.html` file to the `client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html` file in your cloned `a2ui` core repository.
diff --git a/client/ios/run_xcode_ui_tests.sh b/client/ios/run_xcode_ui_tests.sh
new file mode 100755
index 0000000..1ef3ea8
--- /dev/null
+++ b/client/ios/run_xcode_ui_tests.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+set -e
+
+echo "Starting A2UI backend..."
+# Assuming we are in this directory when running this script,
+# we need to go up three levels to find the root 'a2ui'.
+(cd ../../../a2ui && ./run_aikit_demo.sh --local) &
+BACKEND_PID=$!
+
+# Ensure backend is killed when the script exits.
+trap "kill $BACKEND_PID" EXIT
+
+echo "Waiting for backend to start (15s)..."
+sleep 15
+
+# 2. Run the iOS UI tests
+echo "Running iOS UI tests on iPhone 17 Pro..."
+
+# We use xcodebuild to run the tests.
+# https://developer.apple.com/library/archive/technotes/tn2339/_index.html
+xcodebuild \
+ -project A2UI-Example.xcodeproj \
+ -scheme A2UI-Example \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
+ test
+
+echo "UI Tests completed successfully!"
diff --git a/client/web/react/package-lock.json b/client/web/react/package-lock.json
index b12263d..5860ecf 100644
--- a/client/web/react/package-lock.json
+++ b/client/web/react/package-lock.json
@@ -27,7 +27,8 @@
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
- "vite": "^8.0.1"
+ "vite": "^8.0.1",
+ "vite-plugin-singlefile": "^2.3.3"
}
},
"node_modules/@a2a-js/sdk": {
@@ -97,6 +98,33 @@
"@a2ui/web_core": "^0.8.0"
}
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/@a2ui/web_core": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.8.0.tgz",
@@ -1338,6 +1366,19 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2011,6 +2052,19 @@
"node": ">=16.0.0"
}
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2187,6 +2241,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3035,6 +3099,19 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -3252,6 +3329,28 @@
}
}
},
+ "node_modules/vite-plugin-singlefile": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz",
+ "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">18.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^4.59.0",
+ "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/client/web/react/package.json b/client/web/react/package.json
index 031b134..c303071 100644
--- a/client/web/react/package.json
+++ b/client/web/react/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "build:mobile": "VITE_BUILD_TARGET=mobile tsc -b && VITE_BUILD_TARGET=mobile vite build",
"lint": "eslint .",
"preview": "vite preview"
},
@@ -29,6 +30,7 @@
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
- "vite": "^8.0.1"
+ "vite": "^8.0.1",
+ "vite-plugin-singlefile": "^2.3.3"
}
}
diff --git a/client/web/react/src/AppMobile.tsx b/client/web/react/src/AppMobile.tsx
new file mode 100644
index 0000000..e09c0bc
--- /dev/null
+++ b/client/web/react/src/AppMobile.tsx
@@ -0,0 +1,244 @@
+import { useState, useRef, useEffect } from 'react'
+import './App.css'
+import { A2UIRenderer, type TimelineItem, themeStyleSheet } from '@googlemaps/a2ui/lit';
+import { isAndroid, isIOS } from './utils/platform';
+
+const PLACE_CARD_THUMBNAIL_MIN_WIDTH = 350;
+
+/**
+ * Mobile Web Application component that acts as a rendering engine for A2UI surfaces.
+ * It receives JSON payloads from the native iOS/Android bridge and renders them.
+ */
+function AppMobile() {
+ const [timeline, setTimeline] = useState([])
+ const rendererRef = useRef(new A2UIRenderer())
+ const globalDataModelRef = useRef({})
+
+ useEffect(() => {
+ if (!document.adoptedStyleSheets.includes(themeStyleSheet)) {
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, themeStyleSheet];
+ }
+
+ // --- Native Bridge Hack for A2UI-Shell compatibility ---
+ const originalQuerySelector = document.querySelector.bind(document);
+ document.querySelector = function(selectors: K): any {
+ if (selectors as string === 'a2ui-shell') {
+ return {
+ processA2uiMessages: (json: string) => {
+ try {
+ let messages = typeof json === 'string' ? JSON.parse(json) : json;
+ if (typeof messages === 'string') {
+ messages = JSON.parse(messages);
+ }
+
+ if (!Array.isArray(messages)) {
+ messages = [messages];
+ }
+
+ // [A2UI Interceptor]
+ // 1. Auto-fix common LLM hallucinated keys ('latitude' -> 'lat', 'title' -> 'label').
+ function fixKeys(obj: any) {
+ if (Array.isArray(obj)) {
+ obj.forEach(fixKeys);
+ } else if (obj !== null && typeof obj === 'object') {
+ if (obj.latitude !== undefined) { obj.lat = obj.latitude; delete obj.latitude; }
+ if (obj.longitude !== undefined) { obj.lng = obj.longitude; delete obj.longitude; }
+ if (obj.title !== undefined && obj.label === undefined) { obj.label = obj.title; delete obj.title; }
+ Object.values(obj).forEach(fixKeys);
+ }
+ }
+ fixKeys(messages);
+
+ // 2. Track global data model and resolve 'path' references (e.g., Paris map bug).
+ // We must track the model globally because components and data often arrive in separate SSE chunks.
+ let hasUiInstructions = false;
+ const UI_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface', 'beginRendering', 'surfaceUpdate'];
+
+ messages.forEach((item: any) => {
+ // Check if this chunk actually contains UI instructions
+ if (UI_KEYS.some(key => item.hasOwnProperty(key))) {
+ hasUiInstructions = true;
+ }
+
+ if (item.updateDataModel && item.updateDataModel.value) {
+ globalDataModelRef.current = { ...globalDataModelRef.current, ...item.updateDataModel.value };
+ }
+ });
+
+ // 3. Deduplication: Only process this chunk in the WebView IF it contains actual UI instructions.
+ // If it's just pure conversational text, we ignore it here because Android's native bubble handles it.
+ if (!hasUiInstructions) {
+ return;
+ }
+
+ function resolvePath(pathStr: string) {
+ if (!pathStr || !pathStr.startsWith('/')) return null;
+ let parts = pathStr.split('/').filter(Boolean);
+ let curr = globalDataModelRef.current;
+ for (let p of parts) {
+ if (curr && curr.hasOwnProperty(p)) curr = curr[p];
+ else return null;
+ }
+ return curr;
+ }
+
+ function fixGoogleMap(comp: any) {
+ if (comp.component === 'GoogleMap') {
+ if (comp.center && comp.center.path) {
+ let resolved = resolvePath(comp.center.path);
+ if (resolved) comp.center = resolved;
+ }
+ }
+ if (comp.children && Array.isArray(comp.children)) {
+ comp.children.forEach((c: any) => {
+ if (typeof c === 'object') fixGoogleMap(c);
+ });
+ }
+ }
+
+ messages.forEach((item: any) => {
+ if (item.updateComponents && item.updateComponents.components) {
+ let comps = item.updateComponents.components;
+ comps.forEach(fixGoogleMap);
+
+ // Ensure 'root' Column exists for the A2UI Renderer
+ let hasRoot = comps.some((c: any) => c.id === 'root');
+ if (!hasRoot && comps.length > 0) {
+ let rootChildren = comps.map((c: any) => c.id).filter((id: any) => id);
+ comps.unshift({
+ id: 'root',
+ component: 'Column',
+ children: rootChildren
+ });
+ }
+ }
+ });
+
+ rendererRef.current.processResponse(messages.map((msg: any) => ({ type: "a2ui", message: msg })));
+ setTimeline([...rendererRef.current.timeline]);
+ } catch (e) {
+ console.error("Failed to process A2UI JSON:", e);
+ }
+ }
+ };
+ }
+ return originalQuerySelector(selectors);
+ };
+
+ // Resize logic for native platforms
+ const rootElement = document.getElementById('root');
+ if (rootElement) {
+ rootElement.style.height = 'auto';
+ rootElement.style.minHeight = '0';
+ }
+
+ let timeoutId: any = null;
+ const resizeObserver = new ResizeObserver(entries => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ for (let entry of entries) {
+ const newHeight = entry.target.scrollHeight;
+ if (isAndroid && typeof (window as any).Android?.onWebpageResized === 'function') {
+ (window as any).Android.onWebpageResized(newHeight);
+ } else if (isIOS && (window as any).webkit?.messageHandlers?.heightObserver) {
+ (window as any).webkit.messageHandlers.heightObserver.postMessage(newHeight);
+ }
+ }
+ }, 100);
+ });
+
+ const container = document.querySelector('.mobile-app-container') || document.body;
+ if (container) {
+ resizeObserver.observe(container);
+ }
+
+ if (isAndroid && typeof (window as any).Android?.onJsReady === 'function') {
+ (window as any).Android.onJsReady();
+ } else if (isIOS) {
+ (window as any).webkit?.messageHandlers?.iOS?.postMessage({ action: 'onJsReady', data: '' });
+ }
+
+ if (isIOS) {
+ customElements.whenDefined('a2ui-placecard').then(() => {
+ const PlaceCard = customElements.get('a2ui-placecard');
+ if (!PlaceCard) return;
+ const orig = PlaceCard.prototype.firstUpdated;
+
+ PlaceCard.prototype.firstUpdated = function (changedProperties: any) {
+ if (orig) orig.call(this, changedProperties);
+
+ const compact = this.shadowRoot?.querySelector('gmp-place-details-compact') as HTMLElement | null;
+ if (!compact) return;
+
+ new ResizeObserver(() => {
+ const parentWidth = this.clientWidth;
+
+ // The Maps SDK `` hides thumbnails if its container is < 350px.
+ // On standard iOS devices, the chat padding reduces the bubble width below 350px.
+ const targetWidth = PLACE_CARD_THUMBNAIL_MIN_WIDTH;
+
+ if (parentWidth > 0 && parentWidth < targetWidth) {
+ // 1. Force the component to be exactly 350px so the Maps SDK renders the thumbnail image.
+ compact.style.setProperty('width', targetWidth + 'px', 'important');
+ compact.style.setProperty('min-width', targetWidth + 'px', 'important');
+
+ // 2. Visually shrink the 350px component down to fit inside the physical chat bubble width.
+ const scale = parentWidth / targetWidth;
+ compact.style.setProperty('transform-origin', 'top left', 'important');
+ compact.style.setProperty('transform', `scale(${scale})`, 'important');
+
+ // 3. Since `transform: scale()` only changes visual size (not the DOM layout footprint),
+ // the parent container still thinks the component is its original full height.
+ // We apply a negative bottom margin to crop out the empty dead space.
+ const height = compact.offsetHeight;
+ if (height > 0) {
+ compact.style.setProperty('margin-bottom', `-${height * (1 - scale)}px`, 'important');
+ }
+ } else {
+ // 4. If the screen is wide enough (e.g. device rotated to landscape mode),
+ // strip away all hacks and let the component render natively.
+ compact.style.removeProperty('width');
+ compact.style.removeProperty('min-width');
+ compact.style.removeProperty('transform');
+ compact.style.removeProperty('margin-bottom');
+ }
+ }).observe(this);
+ };
+ });
+ }
+
+ return () => {
+ resizeObserver.disconnect();
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ };
+ }, []);
+
+ return (
+
+
+
+ {timeline.length === 0 && (
+ Waiting for payload...
+ )}
+ {timeline.map((item) => {
+ if (item.type === 'surface') {
+ const surface = rendererRef.current.getSurface(item.surfaceId)
+ if (!surface) return null
+ return (
+
+ )
+ }
+ return null
+ })}
+
+
+
+ )
+}
+
+export default AppMobile
diff --git a/client/web/react/src/main.tsx b/client/web/react/src/main.tsx
index bef5202..4f27518 100644
--- a/client/web/react/src/main.tsx
+++ b/client/web/react/src/main.tsx
@@ -1,10 +1,20 @@
-import { StrictMode } from 'react'
+import { StrictMode, Suspense, lazy } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
-import App from './App.tsx'
+import { getAttributionId } from './utils/platform'
+
+// Expose attribution ID globally
+(window as any).A2UI_ATTRIBUTION_ID = getAttributionId();
+
+// This file serves as the main entry point for the googlemaps-samples/a2ui demo project, for both web and mobile implementations. Depending on the build flag, will render either the web implementation or the mobile implementation.
+const App = import.meta.env.VITE_BUILD_TARGET === 'mobile'
+ ? lazy(() => import('./AppMobile.tsx'))
+ : lazy(() => import('./App.tsx'));
createRoot(document.getElementById('root')!).render(
-
+ Loading...}>
+
+
,
)
diff --git a/client/web/react/src/utils/platform.ts b/client/web/react/src/utils/platform.ts
new file mode 100644
index 0000000..6df1ce0
--- /dev/null
+++ b/client/web/react/src/utils/platform.ts
@@ -0,0 +1,25 @@
+/*
+ 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
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+export const isAndroid = typeof window !== 'undefined' && typeof (window as any).Android !== 'undefined';
+export const isIOS = typeof window !== 'undefined' && typeof (window as any).webkit?.messageHandlers?.iOS !== 'undefined';
+export const isMobileWebView = isAndroid || isIOS;
+
+export const getAttributionId = () => {
+ if (isAndroid) return "gmp_web_maui_v0.1.7_exp,gmp_android_maui_v0.1.7_exp";
+ if (isIOS) return "gmp_web_maui_v0.1.7_exp,gmp_ios_maui_v0.1.7_exp";
+ return "gmp_web_maui_v0.1.7_exp";
+};
diff --git a/client/web/react/vite.config.ts b/client/web/react/vite.config.ts
index dec0444..d260b62 100644
--- a/client/web/react/vite.config.ts
+++ b/client/web/react/vite.config.ts
@@ -1,18 +1,40 @@
+/*
+ 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
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import { viteSingleFile } from "vite-plugin-singlefile"
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), {
- name: "html-transform",
- transformIndexHtml(html) {
- return html.replace(
- "$GOOGLE_MAPS_API_KEY",
- process.env.GOOGLE_MAPS_API_KEY || ""
- ).replace(
- "$SERVER_URL",
- process.env.SERVER_URL || "http://localhost:10002"
- );
- },
- },],
+ base: './',
+ plugins: [
+ react(),
+ ...(process.env.VITE_BUILD_TARGET === 'mobile' ? [viteSingleFile()] : []),
+ {
+ name: "html-transform",
+ transformIndexHtml(html) {
+ return html.replace(
+ "$GOOGLE_MAPS_API_KEY",
+ process.env.GOOGLE_MAPS_API_KEY || "$GOOGLE_MAPS_API_KEY"
+ ).replace(
+ "$SERVER_URL",
+ process.env.SERVER_URL || "http://localhost:10002"
+ );
+ },
+ }
+ ],
})
\ No newline at end of file