diff --git a/README.md b/README.md index eff55dc..75a6a9f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ [![Web CI](https://github.com/googlemaps/a2ui/actions/workflows/web-ci.yml/badge.svg)](https://github.com/googlemaps/a2ui/actions/workflows/web-ci.yml) [![GitHub License](https://img.shields.io/github/license/googlemaps/a2ui?color=blue&v=1)](LICENSE) [![npm](https://img.shields.io/npm/v/@googlemaps/a2ui)](https://npmjs.com/package/@googlemaps/a2ui) +[![Android Library](https://img.shields.io/badge/Android_Library-Kotlin-3DDC84)](https://github.com/googlemaps/a2ui/tree/main/client/android) +[![iOS Library](https://img.shields.io/badge/iOS_Library-SwiftUI-007AFF)](https://github.com/googlemaps/a2ui/tree/main/client/ios) > **Note:** This toolkit is in **Experimental** status. @@ -16,11 +18,14 @@ It makes use of the following technologies: * [A2UI](https://a2ui.org/) for the Agent-driven dynamic UI protocol. * [ADK](https://google.github.io/adk-docs/) for defining the Agent implementation. * [A2A Python](https://github.com/a2aproject/a2a-python) for the A2A server implementation. -* [Lit](https://lit.dev/) for the rendering framework for A2UI components. +* Client rendering frameworks for multi-platform support: + * [Lit](https://lit.dev/) for the Web A2UI client rendering. + * Native rendering libraries for Android (Android Views) and iOS (SwiftUI). ## Quickstart Guide -To quickly get started, we recommend using the [Agentic UI Toolkit samples project](https://github.com/googlemaps-samples/a2ui). This sample project contains the necessary components to run the Python Agent and a React web client that allows you to interact with the agent. +To quickly get started, we recommend using the [Agentic UI Toolkit samples project](https://github.com/googlemaps-samples/a2ui). This sample project contains the necessary components to run the Python Agent and interactive client applications (React for Web, Android Views for Android, and SwiftUI for iOS) to interact with the agent. + ### Prerequisites and Tool Setup @@ -62,6 +67,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 (for Android & iOS) +* **Android Development**: [Android Studio](https://developer.android.com/studio) and the Android SDK (API 24+ recommended). +* **iOS Development**: macOS with [Xcode](https://developer.apple.com/xcode/) (v15.0+ recommended) and CocoaPods or Swift Package Manager (SPM). + --- ## MAUI Python Agent Package (`maui-a2ui-python`) @@ -174,11 +183,13 @@ class MAUIAgentExecutor(AgentExecutor): raise ServerError(error=UnsupportedOperationError()) ``` -## Agentic UI Toolkit Web Client Library +## Agentic UI Toolkit Client Libraries -This package provides the Web (Lit-based) client library for the Maps Agentic UI Toolkit (MAUI). It includes components and utilities to render A2UI surfaces and communicate with an A2A agent server. +This repository provides A2UI client libraries across multiple platforms (Web, Android, and iOS) to render interactive A2UI surfaces and communicate with an A2A agent server. -### How to Integrate +### 🌐 Web Client Library (`@googlemaps/a2ui`) + +This package provides the Web (Lit-based) client library for the Maps Agentic UI Toolkit (MAUI). It includes components and utilities to render A2UI surfaces and communicate with an A2A agent server. To integrate these components into an existing application, you can refer to the [Agentic UI Toolkit samples project](https://github.com/googlemaps-samples/a2ui). @@ -241,11 +252,11 @@ return ( ); ``` -### Local Development +#### Local Development To make changes to this package and test them in an application: -#### 1. Build and Prepare the Package +##### 1. Build and Prepare the Package To build the package for use in an application: @@ -256,12 +267,26 @@ To build the package for use in an application: ``` -#### 2. Link the Package +##### 2. Link the Package You can consume the package via npm linking for local development: ```bash # In your application directory npm link @googlemaps/a2ui ``` + +### 🤖 Android Client Library + +This package provides the native Android client library for the Maps Agentic UI Toolkit (MAUI), implemented using Android Views and the Google Maps SDK for Android. It includes native Android View components to render A2UI surfaces in Android apps. + +For complete integration instructions, setup, and configuration details, refer to the [Android Client README](client/android/README.md). + +### 🍎 iOS Client Library + +This package provides the native iOS client library for the Maps Agentic UI Toolkit (MAUI), implemented using SwiftUI and the Google Maps SDK for iOS. It includes native SwiftUI components to render A2UI surfaces in iOS apps. + +For complete integration instructions, setup, and configuration details, refer to the [iOS Client README](client/ios/README.md). + + ## Google API Keys ### Google Maps API Key diff --git a/client/android/GoogleMapsA2UI/.gitignore b/client/android/GoogleMapsA2UI/.gitignore new file mode 100644 index 0000000..8ca7dab --- /dev/null +++ b/client/android/GoogleMapsA2UI/.gitignore @@ -0,0 +1,13 @@ +# Built artifacts +bin/ +gen/ +out/ +build/ + +# Gradle +.gradle/ +.kotlin/ +local.properties + +# Android Studio / IntelliJ +.idea/ diff --git a/client/android/GoogleMapsA2UI/build.gradle b/client/android/GoogleMapsA2UI/build.gradle new file mode 100644 index 0000000..702c9a0 --- /dev/null +++ b/client/android/GoogleMapsA2UI/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'com.android.library' version '9.0.0' + id 'maven-publish' +} + +android { + namespace 'com.google.android.libraries.mapsplatform.a2ui' + compileSdk 34 + + defaultConfig { + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + + publishing { + singleVariant('release') + } +} + +repositories { + google() + mavenCentral() +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.11.1' +} + +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + from components.release + groupId = 'com.google.android.libraries.mapsplatform.a2ui' + artifactId = 'GoogleMapsA2UI' + version = '0.1.0' + } + } + } +} diff --git a/client/android/GoogleMapsA2UI/gradle/wrapper/gradle-wrapper.jar b/client/android/GoogleMapsA2UI/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/client/android/GoogleMapsA2UI/gradle/wrapper/gradle-wrapper.jar differ diff --git a/client/android/GoogleMapsA2UI/gradle/wrapper/gradle-wrapper.properties b/client/android/GoogleMapsA2UI/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/client/android/GoogleMapsA2UI/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/GoogleMapsA2UI/gradlew b/client/android/GoogleMapsA2UI/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/client/android/GoogleMapsA2UI/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/GoogleMapsA2UI/gradlew.bat b/client/android/GoogleMapsA2UI/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/client/android/GoogleMapsA2UI/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/GoogleMapsA2UI/settings.gradle b/client/android/GoogleMapsA2UI/settings.gradle new file mode 100644 index 0000000..cd4a393 --- /dev/null +++ b/client/android/GoogleMapsA2UI/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'GoogleMapsA2UI' diff --git a/client/android/GoogleMapsA2UI/src/main/AndroidManifest.xml b/client/android/GoogleMapsA2UI/src/main/AndroidManifest.xml new file mode 100644 index 0000000..69b8f9e --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/client/android/GoogleMapsA2UI/src/main/assets/index.html b/client/android/GoogleMapsA2UI/src/main/assets/index.html new file mode 100644 index 0000000..b5c0f00 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/main/assets/index.html @@ -0,0 +1,1182 @@ + + + + + + + + AI Kit React Reference Implementation + + + + +
+ + \ No newline at end of file diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt new file mode 100644 index 0000000..632b7de --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt @@ -0,0 +1,308 @@ +// 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.google.android.libraries.mapsplatform.a2ui + +import org.json.JSONArray +import org.json.JSONObject + +data class ParsedA2AEventMetadata(val mimeType: String?) + +sealed interface ParsedA2AEvent { + data class Text(val text: String) : ParsedA2AEvent + data class Data(val data: String, val metadata: ParsedA2AEventMetadata? = null) : ParsedA2AEvent +} + +object A2AResponseParser { + + private const val KEY_TEXT = "text" + private const val KEY_KIND = "kind" + private const val KEY_DATA = "data" + private const val KEY_HISTORY = "history" + private const val KEY_ROLE = "role" + private const val VAL_USER = "user" + private const val VAL_AGENT = "agent" + private const val KEY_PARTS = "parts" + private const val KEY_CONTENT = "content" + private const val KEY_STATUS = "status" + private const val KEY_MESSAGE = "message" + private const val KEY_RESULT = "result" + + private const val KEY_CREATE_SURFACE = "createSurface" + private const val KEY_UPDATE_COMPONENTS = "updateComponents" + private const val KEY_UPDATE_DATA_MODEL = "updateDataModel" + private const val KEY_DELETE_SURFACE = "deleteSurface" + private const val KEY_SURFACE_ID = "surfaceId" + + fun parse(rawJson: JSONObject): List { + val partsList = mutableListOf() + val partsArray = extractPartsArray(rawJson) + + if (partsArray != null) { + var currentTextBuilder = java.lang.StringBuilder() + var currentUiElements = JSONArray() + + for (i in 0 until partsArray.length()) { + val part = partsArray.getJSONObject(i) + val textPart = if (part.has(KEY_TEXT)) part.optString(KEY_TEXT) else if (part.optString(KEY_KIND) == KEY_TEXT) part.optString(KEY_TEXT) else null + + if (textPart != null) { + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) + currentUiElements = JSONArray() + } + + if (textPart.contains("---a2ui_JSON---") || textPart.contains("```json")) { + extractJsonBlocks(textPart, currentTextBuilder, partsList) + } else if (textPart.isNotEmpty()) { + if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") + currentTextBuilder.append(textPart) + } + } + + val dataPayload = if (part.has(KEY_DATA)) part.optJSONObject(KEY_DATA) else if (part.optString(KEY_KIND) == KEY_DATA) part.optJSONObject(KEY_DATA) else null + if (dataPayload != null && isUiElement(dataPayload)) { + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + currentTextBuilder = java.lang.StringBuilder() + } + currentUiElements.put(dataPayload) + } + } + + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + } + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) + } + } else { + try { + val resultObj = rawJson.opt(KEY_RESULT) + if (resultObj is String) { + if (resultObj.isNotEmpty()) { + val firstChar = resultObj.trim().firstOrNull() + if (firstChar == '[') { + val array = JSONArray(resultObj) + val uiElements = JSONArray() + for (j in 0 until array.length()) { + val item = array.optJSONObject(j) + if (item != null && isUiElement(item)) { + uiElements.put(item) + } + } + if (uiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(uiElements.toString())) + } + } + } + } else if (resultObj is JSONArray) { + var currentTextBuilder = java.lang.StringBuilder() + val uiElements = JSONArray() + for (j in 0 until resultObj.length()) { + val item = resultObj.optJSONObject(j) + if (item != null) { + if (item.has(KEY_TEXT)) { + val textPart = item.optString(KEY_TEXT) + if (textPart.isNotEmpty()) { + if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") + currentTextBuilder.append(textPart) + } + } + if (isUiElement(item)) { + uiElements.put(item) + } + } + } + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + } + if (uiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(uiElements.toString())) + } + } + } catch (e: Exception) {} + } + + val deduplicatedParts = mutableListOf() + val seenSurfaces = mutableSetOf() + var lastSeenText: String? = null + + for (part in partsList) { + val finalText: String? = if (part is ParsedA2AEvent.Text) { + part.text.replace("```json", "").replace("```", "").trim().takeIf { it.isNotEmpty() } + } else null + + // Deduplicate consecutive identical text blocks + if (finalText != null && finalText != lastSeenText) { + deduplicatedParts.add(ParsedA2AEvent.Text(finalText)) + lastSeenText = finalText + } + + if (part is ParsedA2AEvent.Data && part.data != "[]") { + try { + val array = JSONArray(part.data) + val newArray = JSONArray() + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val sid = obj.optJSONObject(KEY_CREATE_SURFACE)?.optString(KEY_SURFACE_ID) + if (sid != null) { + if (seenSurfaces.contains(sid)) { + continue + } + seenSurfaces.add(sid) + } + newArray.put(obj) + } + if (newArray.length() > 0) { + deduplicatedParts.add(ParsedA2AEvent.Data(newArray.toString(), part.metadata)) + } + } catch (e: Exception) { + if (part.data.isNotEmpty()) { + deduplicatedParts.add(part) + } + } + } + } + + return deduplicatedParts + } + + private fun isUiElement(obj: JSONObject): Boolean { + return obj.has(KEY_CREATE_SURFACE) || + obj.has(KEY_UPDATE_COMPONENTS) || + obj.has(KEY_UPDATE_DATA_MODEL) || + obj.has(KEY_DELETE_SURFACE) + } + + private fun extractPartsArray(rawJson: JSONObject): JSONArray? { + val finalParts = JSONArray() + + if (rawJson.has(KEY_HISTORY)) { + val history = rawJson.optJSONArray(KEY_HISTORY) + if (history != null) { + var lastUserIndex = -1 + for (i in 0 until history.length()) { + val msg = history.optJSONObject(i) + if (msg?.optString(KEY_ROLE) == VAL_USER) { + lastUserIndex = i + } + } + + for (i in (lastUserIndex + 1) until history.length()) { + val msg = history.optJSONObject(i) + if (msg?.optString(KEY_ROLE) == VAL_AGENT) { + val parts = msg.optJSONArray(KEY_PARTS) + if (parts != null) { + for (j in 0 until parts.length()) { + finalParts.put(parts.getJSONObject(j)) + } + } + } + } + } + } + + val additionalParts = when { + rawJson.has(KEY_PARTS) -> rawJson.optJSONArray(KEY_PARTS) + rawJson.has(KEY_CONTENT) -> rawJson.optJSONObject(KEY_CONTENT)?.optJSONArray(KEY_PARTS) + rawJson.has(KEY_STATUS) -> rawJson.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) + rawJson.has(KEY_RESULT) -> { + val resultObj = rawJson.opt(KEY_RESULT) + if (resultObj is String) { + try { + val innerJson = JSONObject(resultObj) + innerJson.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) + } catch (e: Exception) { + null + } + } else if (resultObj is JSONObject) { + resultObj.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) + } else { + null + } + } + else -> null + } + + if (additionalParts != null) { + for (i in 0 until additionalParts.length()) { + finalParts.put(additionalParts.getJSONObject(i)) + } + } + + return if (finalParts.length() > 0) finalParts else null + } + + private fun extractJsonBlocks(textPart: String, textBuilder: java.lang.StringBuilder, partsList: MutableList) { + val jsonPattern = "```json(.*?)```".toRegex(RegexOption.DOT_MATCHES_ALL) + val a2uiPattern = "---a2ui_JSON---(.*?)---a2ui_JSON_END---".toRegex(RegexOption.DOT_MATCHES_ALL) + + val allMatches = mutableListOf() + allMatches.addAll(jsonPattern.findAll(textPart)) + allMatches.addAll(a2uiPattern.findAll(textPart)) + + if (allMatches.isEmpty()) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(textPart) + return + } + + allMatches.sortBy { it.range.first } + + var lastEnd = 0 + for (match in allMatches) { + val beforeText = textPart.substring(lastEnd, match.range.first).trim() + if (beforeText.isNotEmpty()) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(beforeText) + } + + val jsonString = match.groupValues[1].trim() + try { + val firstChar = jsonString.firstOrNull() + if (firstChar == '[') { + if (textBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) + textBuilder.clear() + } + val array = JSONArray(jsonString) + val localUiElements = JSONArray() + for (i in 0 until array.length()) { + localUiElements.put(array.getJSONObject(i)) + } + partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) + } else if (firstChar == '{') { + if (textBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) + textBuilder.clear() + } + val localUiElements = JSONArray() + localUiElements.put(JSONObject(jsonString)) + partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) + } + } catch (e: Exception) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(match.value) + } + lastEnd = match.range.last + 1 + } + + val remainingText = textPart.substring(lastEnd).trim() + if (remainingText.isNotEmpty()) { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(remainingText) + } + } +} diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt new file mode 100644 index 0000000..4f2027c --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServices.kt @@ -0,0 +1,24 @@ +// 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.google.android.libraries.mapsplatform.a2ui + +object A2UIServices { + var apiKey: String = "" + private set + + fun provideAPIKey(key: String) { + apiKey = key + } +} \ No newline at end of file diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt new file mode 100644 index 0000000..7b8b6f5 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt @@ -0,0 +1,169 @@ +// 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.google.android.libraries.mapsplatform.a2ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.AttributeSet +import android.util.Log +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import java.io.BufferedReader +import java.io.InputStreamReader + +class A2UIView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : WebView(context, attrs, defStyleAttr) { + + private val A2UI_DEBUG_TAG = "A2UIViewDebug" + private val A2UI_CONSOLE_TAG = "A2UIViewConsole" + private val A2UI_ERROR_TAG = "A2UIViewError" + private val MAPS_HOST = "maps.google.com" + private val MAPS_PATH = "google.com/maps" + private val MAPS_PACKAGE = "com.google.android.apps.maps" + private val HTTP_SCHEME = "http://" + private val HTTPS_SCHEME = "https://" + + private var indexHtml: String = "" + var a2uiJson: String = "" + private var startTime: Long? = null + var onRenderComplete: ((latencyMs: Long, status: String) -> Unit)? = null + var onUserAction: ((actionJson: String) -> Unit)? = null + + private var isJsReady: Boolean = false + private var lastInjectedElementCount: Int = 0 + + private val webAppInterface = WebAppInterface(this, this) + + init { + indexHtml = loadIndexHtml(context) + setupWebView() + } + + private fun setupWebView() { + settings.javaScriptEnabled = true + settings.allowFileAccess = true + settings.allowContentAccess = true + settings.allowFileAccessFromFileURLs = true + settings.allowUniversalAccessFromFileURLs = true + + addJavascriptInterface(webAppInterface, "Android") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + setWebContentsDebuggingEnabled(true) + } + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url?.toString() ?: return false + if (url.startsWith(HTTP_SCHEME) || url.startsWith(HTTPS_SCHEME)) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + if (url.contains(MAPS_HOST) || url.contains(MAPS_PATH)) { + intent.setPackage(MAPS_PACKAGE) + if (intent.resolveActivity(context.packageManager) == null) { + intent.setPackage(null) + } + } + context.startActivity(intent) + return true + } + return super.shouldOverrideUrlLoading(view, request) + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + Log.e(A2UI_ERROR_TAG, "Error loading WebView: ${error?.description}, URL: ${request?.url}") + } + } + } + + private fun loadIndexHtml(context: Context): String { + return try { + val inputStream = context.assets.open("index.html") + val reader = BufferedReader(InputStreamReader(inputStream)) + reader.readText() + } catch (e: Exception) { + Log.e(A2UI_ERROR_TAG, "Failed to load index.html from assets: ${e.message}") + "" + } + } + + fun render(json: String, startTimeMs: Long? = null) { + Log.d(A2UI_DEBUG_TAG, "Rendering A2UI JSON") + this.startTime = startTimeMs ?: System.currentTimeMillis() + this.a2uiJson = json + this.isJsReady = false + this.lastInjectedElementCount = 0 + val apiKey = A2UIServices.apiKey + val htmlToLoad = indexHtml.replace("\$GOOGLE_MAPS_API_KEY", apiKey) + loadDataWithBaseURL("file:///android_asset/", htmlToLoad, "text/html", "UTF-8", null) + } + + fun updateA2uiJson(newJson: String) { + this.a2uiJson = newJson + if (!isJsReady) return + + webAppInterface.resized = false + + post { + try { + val escapedJson = org.json.JSONObject.quote(newJson) + val script = """ + try { + const shell = document.querySelector('a2ui-shell'); + if (shell) { + // Send the raw JSON payload to the frontend. + // The frontend (AppMobile.tsx) now handles hallucination fixes and path resolution internally. + shell.processA2uiMessages(${escapedJson}); + } else { + console.error('a2ui-shell not found'); + } + } catch (e) { + console.error('Error in evaluateJavascript: ' + e); + } + """.trimIndent() + evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(A2UI_ERROR_TAG, "Error processing a2ui update", e) + } + } + } + + internal fun onJsReadyInternal() { + Log.d(A2UI_DEBUG_TAG, "a2ui-shell is fully ready!") + isJsReady = true + if (a2uiJson.isNotEmpty()) { + updateA2uiJson(a2uiJson) + } + } + + internal fun onRenderCompleteInternal() { + startTime?.let { + val latency = System.currentTimeMillis() - it + onRenderComplete?.invoke(latency, "A2UI Render Complete") + startTime = null + } + } +} diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt new file mode 100644 index 0000000..391d17b --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt @@ -0,0 +1,91 @@ +// 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.google.android.libraries.mapsplatform.a2ui + +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.ValueCallback +import android.webkit.WebView +import org.json.JSONException +import org.json.JSONObject + +class WebAppInterface( + private val webView: WebView, + private val a2uiView: A2UIView +) { + private val TAG = "A2UIWebAppInterface" + var resized = false + + @JavascriptInterface + fun sendA2uiMessages(jsonMessages: String) { + Log.d(TAG, "sendA2uiMessages called with: $jsonMessages") + resized = false + webView.post { + val escapedJson = JSONObject.quote(jsonMessages) + val script = """ + try { + const shell = document.querySelector('a2ui-shell'); + if (shell) { + console.log('WebAppInterface: Calling processA2uiMessages'); + shell.processA2uiMessages($escapedJson); + } else { + console.error('WebAppInterface: a2ui-shell element not found.'); + } + } catch (e) { + console.error('WebAppInterface: Error in evaluateJavascript: ' + e.message); + } + """.trimIndent() + webView.evaluateJavascript(script, ValueCallback { value -> + Log.d(TAG, "JavaScript evaluation result: $value") + }) + } + } + + @JavascriptInterface + fun onGetDirections(jsonString: String) { + Log.d(TAG, "onGetDirections: $jsonString") + a2uiView.onUserAction?.invoke(jsonString) + } + + @JavascriptInterface + fun onWebpageResized(height: Int) { + Log.d(TAG, "onWebpageResized: $height") + if (!resized) { + webView.post { + val layoutParams = webView.layoutParams + if (layoutParams != null) { + val newHeight = (height * webView.resources.displayMetrics.density).toInt() + layoutParams.height = newHeight + webView.layoutParams = layoutParams + resized = true + Log.d("A2UIViewDebug", "WebView height updated to: $newHeight") + a2uiView.onRenderCompleteInternal() + } else { + Log.e("A2UIViewDebug", "WebView LayoutParams is null.") + } + } + } + } + + @JavascriptInterface + fun onJsReady() { + Log.d(TAG, "onJsReady") + Handler(Looper.getMainLooper()).post { + a2uiView.onJsReadyInternal() + } + } +} diff --git a/client/android/GoogleMapsA2UI/src/test/README.md b/client/android/GoogleMapsA2UI/src/test/README.md new file mode 100644 index 0000000..0817aad --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/README.md @@ -0,0 +1,8 @@ +# Running Unit Tests + +To verify the core parsing logic (`A2AResponseParser`) and ensure payload compatibility, run the headless JUnit tests via the terminal from the SDK root: + +```bash +./gradlew testDebugUnitTest +``` +*(Or simply right-click `A2AResponseParserTest.kt` in Android Studio and select "Run".)* \ No newline at end of file diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt new file mode 100644 index 0000000..9bef328 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt @@ -0,0 +1,122 @@ +// 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.google.android.libraries.mapsplatform.a2ui + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class A2AResponseParserTest { + + @Test + fun testParse_InvalidPayloadStructure() { + // Test that an unexpected payload format returns an empty event list safely + val payloadWithNoParts = JSONObject().apply { + put("status", "ok") + } + val events = A2AResponseParser.parse(payloadWithNoParts) + assertEquals(0, events.size) + } + + @Test + fun testParse_SimpleTextPart() { + // Test standard text extraction from a standard payload structure + val payload = JSONObject(""" + { + "parts": [ + {"text": "Show me some good sushi in Seattle"} + ] + } + """.trimIndent()) + + val events = A2AResponseParser.parse(payload) + assertEquals(1, events.size) + val textEvent = events[0] as ParsedA2AEvent.Text + assertEquals("Show me some good sushi in Seattle", textEvent.text) + } + + @Test + fun testParse_TextConcatenation() { + // Android parser concatenates all text elements within a JSONArray into a single text event + val payload = JSONObject(""" + { + "result": [ + {"text": "Hello Seattle!"}, + {"text": "Hello Seattle!"}, + {"text": "Different text."} + ] + } + """.trimIndent()) + + val events = A2AResponseParser.parse(payload) + assertEquals(1, events.size) // Expecting 1 because texts are concatenated + assertEquals("Hello Seattle!\nHello Seattle!\nDifferent text.", (events[0] as ParsedA2AEvent.Text).text) + } + + @Test + fun testParse_StringifiedJsonResultArray() { + // Tests the scenario where 'result' contains a stringified JSON array starting with '[' + val stringifiedArray = """[{"createSurface": {"surfaceId": "sushi-seattle"}}]""" + val payload = JSONObject().apply { + put("result", stringifiedArray) + } + + val events = A2AResponseParser.parse(payload) + assertEquals(1, events.size) + + val dataEvent = events[0] as ParsedA2AEvent.Data + val a2uiArray = JSONArray(dataEvent.data) + assertEquals(1, a2uiArray.length()) + assertTrue(a2uiArray.getJSONObject(0).has("createSurface")) + } + + @Test + fun testParse_NativeJsonResultArray() { + // Tests the newly added support for native JSONArray inside the 'result' key (from PR #311 fixes) + val payload = JSONObject(""" + { + "result": [ + { + "text": "Here is your native array map" + }, + { + "updateComponents": { + "surfaceId": "sushi-seattle", + "components": [] + } + } + ] + } + """.trimIndent()) + + val events = A2AResponseParser.parse(payload) + + // We expect one Text event and one Data event + assertEquals(2, events.size) + + val textEvent = events[0] as ParsedA2AEvent.Text + assertEquals("Here is your native array map", textEvent.text) + + val dataEvent = events[1] as ParsedA2AEvent.Data + val a2uiArray = JSONArray(dataEvent.data) + assertEquals(1, a2uiArray.length()) + assertTrue(a2uiArray.getJSONObject(0).has("updateComponents")) + } +} \ No newline at end of file diff --git a/client/android/README.md b/client/android/README.md new file mode 100644 index 0000000..a00df07 --- /dev/null +++ b/client/android/README.md @@ -0,0 +1,135 @@ +# GoogleMapsA2UI Android Library + +![Alpha](https://img.shields.io/badge/release-alpha-orange) + +## Overview +The **GoogleMapsA2UI** library is an Android SDK designed to encapsulate the parsing and rendering of Google Maps Platform Agent-to-UI (A2UI) payloads. It seamlessly converts complex A2A JSON responses into native-friendly, rich map interfaces using a specialized WebView-based component. + +It makes use of the following technologies: +* **Google Maps Platform** for rendering maps and places. +* **A2UI** for the Agent-driven dynamic UI protocol. +* **React** for the underlying web-based rendering engine. + +## Prerequisites +* **Protocol Version:** This library is built based on the **v0.9 A2UI protocol** and is **not backward compatible** with the v0.8 protocol. Ensure your backend server uses the v0.9 protocol format. +* **Android Studio:** Koala (2024.1.1) or later. +* **Android SDK:** + * **Library:** API Level 24 or later. + * **Sample App:** API Level 26 or later. +* **Java:** JDK 17. +* **Google Maps API Key:** Required for rendering map components. + +## Build the Library + +Before building any sample applications that depend on this library, you must build and publish it to your local Maven repository. + +1. Open a terminal and navigate to the Library directory: + ```bash + cd ~/ai-kit/a2ui/client/android/GoogleMapsA2UI + ``` +2. Publish the Library to your local Maven repository: + ```bash + ./gradlew publishToMavenLocal + ``` + +## SDK Reference & Usage + +### 1. Global Initialization +Configure the Google Maps API Key once at the application level (e.g., in `MainActivity.onCreate` or a custom `Application` class) before any A2UI components are rendered. + +```kotlin +import com.google.android.gms.maps.a2ui.A2UIServices + +// Initialize the SDK configuration globally once +A2UIServices.provideAPIKey("YOUR_GOOGLE_MAPS_API_KEY") +``` + +### 2. Parse the Server Response +Use the `A2AResponseParser` to safely extract payloads from the raw backend JSON tree into an ordered list of `ParsedA2AEvent` objects. This preserves the sequential order of conversational text and rich UI. *(Note: The snippet below is simplified pseudocode. For the complete implementation handling streaming aggregation, refer to `MainActivity.kt` in the sample app).* + +```kotlin +import com.google.android.gms.maps.a2ui.A2AResponseParser + +// SDK parses the response into an ordered list of parts +val parsedParts = A2AResponseParser.parse(rawJson) + +for (part in parsedParts) { + when (part) { + is ParsedA2AEvent.Text -> { + // Render plain conversational text in your native chat bubbles + chatAdapter.addTextMessage(part.text) + } + is ParsedA2AEvent.Data -> { + // Feed the structured data into A2UIView + chatAdapter.addGmpA2UIViewMessage(part.data) + } + } +} +``` + +### 3. Rendering the View +The library provides `A2UIView`, a custom component that manages the rendering of rich map interfaces. It handles dynamic height resizing and user interaction callbacks automatically. + +**XML Layout:** +```xml + +``` + +**ViewHolder Implementation:** +```kotlin +class GmpA2UIViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val gmpA2UIView: A2UIView = itemView.findViewById(R.id.gmpA2UIView) + + fun bind(a2uiJson: String) { + gmpA2UIView.render(a2uiJson) + } + + // Support real-time SSE streaming updates + fun updateStreamingJson(newJson: String) { + gmpA2UIView.updateA2uiJson(newJson) + } +} +``` + +## Architecture Deep Dive + +The SDK encapsulates all complex parsing and rendering logic into four core components: + +1. **Data Model (`ParsedA2AEvent`)**: A standardized, ordered sealed interface outputting plain text and extracted JSON (data), supporting sequential rendering for the host app. +2. **The Parser (`A2AResponseParser`)**: A static utility that navigates complex JSON trees, uses Regex to extract payloads from Markdown blocks, and handles deduplication of redundant UI commands. +3. **Core Visual Component (`A2UIView`)**: A custom WebView-based component that handles local asset loading, API key injection, and URL interception (launching the native Maps app). +4. **JS Communication Bridge (`WebAppInterface`)**: Manages bidirectional communication. It sends data from Android to JS and receives callbacks for events like webpage resizing and user actions. + + +## Updating the React Frontend (React Renderer Updates) + +The `GoogleMapsA2UI` library relies on a pre-built React web frontend bundle (`index.html`) which is shipped inside its `assets` folder. + +If you have customized your `internal-usage-attribution-ids` or modified the underlying web components, you must recompile the frontend and bundle it back into this Android Library. + +Steps to update the React renderer with your customizations: + +1. **Build the local A2UI web library:** + ```bash + cd ~/ai-kit/a2ui/client/web + npm run build-and-link + ``` + +2. **Rebuild the React app and bundle it into a single HTML file:** + ```bash + cd ~/ai-kit/a2ui-samples/client/web/react + npm install + npm link @googlemaps/a2ui + npm run build:mobile + ``` + +3. **Copy the compiled `index.html` into the Android Library's assets folder:** + ```bash + cp ~/ai-kit/a2ui-samples/client/web/react/dist/index.html ~/ai-kit/a2ui/client/android/GoogleMapsA2UI/src/main/assets/ + ``` + +4. **Re-publish the Library:** + Finally, re-publish the SDK to Maven Local (Step 2 above) and reinstall your Android application to see the changes. \ No newline at end of file diff --git a/client/ios/GoogleMapsA2UI/.gitignore b/client/ios/GoogleMapsA2UI/.gitignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/client/ios/GoogleMapsA2UI/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/client/ios/GoogleMapsA2UI/Package.swift b/client/ios/GoogleMapsA2UI/Package.swift new file mode 100644 index 0000000..b207e23 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Package.swift @@ -0,0 +1,46 @@ +// swift-tools-version: 5.9 +// +// 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 PackageDescription + +let package = Package( + name: "GoogleMapsA2UI", + platforms: [ + .iOS(.v16) + ], + products: [ + .library( + name: "GoogleMapsA2UI", + targets: ["GoogleMapsA2UI"] + ), + ], + dependencies: [], + targets: [ + .target( + name: "GoogleMapsA2UI", + dependencies: [], + resources: [ + .copy("Resources/index.html") + ] + ), + .testTarget( + name: "GoogleMapsA2UITests", + dependencies: ["GoogleMapsA2UI"] + ), + ] +) + diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift new file mode 100644 index 0000000..5477fb3 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2AResponseParser.swift @@ -0,0 +1,178 @@ +// +// 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 + +/// Errors that can occur during A2UI response parsing. +public enum A2AParserError: Error { + /// The raw JSON response lacks a recognized message parts structure. + case invalidPayloadStructure + /// The provided dictionary cannot be serialized as valid JSON. + case invalidJSONFormat +} + +/// Utility parser to process server JSON responses. +/// Declared as a case-less enum to prevent instantiation. +public enum A2AResponseParser { + private static let a2uiJsonMimeType = "application/json+a2ui" + private static let a2uiJsonTagOpen = "" + private static let a2uiJsonTagClose = "" + + /// Parses a raw server response dictionary into a flat list of `ParsedA2AEvent`s. + /// + /// The parser checks multiple possible JSON paths (`parts`, `content.parts`, `status.message.parts`) + /// to support varying response structures from different server backends (e.g. standalone JSON-RPC vs. ADK Web Server). + /// + /// Note: Any extracted A2UI JSON payloads (mime type `application/json+a2ui`) will always be batched + /// and returned as an array (`[Any]`) inside `ParsedA2AEvent.data`, providing a consistent format. + /// + /// - Parameter rawJSON: The raw JSON dictionary received from the server. + /// - Returns: An array of `ParsedA2AEvent` objects extracted from the payload. + /// - Throws: `A2AParserError.invalidJSONFormat` if the input is not valid JSON, or `A2AParserError.invalidPayloadStructure` if the parts array cannot be found. + public static func parse(_ rawJSON: [String: Any]) throws -> [ParsedA2AEvent] { + guard JSONSerialization.isValidJSONObject(rawJSON) else { + throw A2AParserError.invalidJSONFormat + } + + let partsArray: [[String: Any]]? + if let parts = rawJSON["parts"] as? [[String: Any]] { + partsArray = parts + } else if let content = rawJSON["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]] { + partsArray = parts + } else if let status = rawJSON["status"] as? [String: Any], + let message = status["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + partsArray = parts + } else { + partsArray = nil + } + + guard let parts = partsArray else { + throw A2AParserError.invalidPayloadStructure + } + + var sdkParts: [ParsedA2AEvent] = [] + var a2uiPayloads: [Any] = [] + + // flushA2UI() batches consecutive A2UI JSON payloads together into a single ParsedA2AEvent. + func flushA2UI() { + if !a2uiPayloads.isEmpty { + let event = ParsedA2AEvent.data( + a2uiPayloads, + metadata: ParsedA2AEventMetadata(mimeType: a2uiJsonMimeType) + ) + sdkParts.append(event) + a2uiPayloads.removeAll() + } + } + + for part in parts { + if let textPart = part["text"] as? String { + flushA2UI() + let subEvents = splitTextParts(textPart) + sdkParts.append(contentsOf: subEvents) + } else if let dataPayload = part["data"] as? [String: Any] { + let metadata = part["metadata"] as? [String: Any] + let mimeType = part["mimeType"] as? String ?? metadata?["mimeType"] as? String + let resolvedMimeType = mimeType + ?? (isA2UIPayload(dataPayload) ? a2uiJsonMimeType : nil) + + if resolvedMimeType == a2uiJsonMimeType { + a2uiPayloads.append(dataPayload) + } else { + flushA2UI() + let event = ParsedA2AEvent.data( + dataPayload, + metadata: ParsedA2AEventMetadata(mimeType: resolvedMimeType) + ) + sdkParts.append(event) + } + } + } + + flushA2UI() + + return sdkParts + } + + private static let a2uiKeys: Set = [ + "createSurface", "updateComponents", "updateDataModel", + "beginRendering", "surfaceUpdate", "dataModelUpdate" + ] + + /// Checks if a given dictionary represents an A2UI payload. + /// + /// - Parameter dict: The dictionary to check. + /// - Returns: `true` if the dictionary contains recognizable A2UI keys; otherwise, `false`. + private static func isA2UIPayload(_ dict: [String: Any]) -> Bool { + return dict.keys.contains { a2uiKeys.contains($0) } + } + + /// Parses a JSON string into a dictionary or array. + /// + /// - Parameter jsonStr: The JSON string to parse. + /// - Returns: The parsed JSON object, or `nil` if parsing fails. + private static func parseJSON(_ jsonStr: String) -> Any? { + guard let data = jsonStr.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) + } + + /// Splits a text part containing embedded `` tags into separate events. + /// + /// - Parameter textPart: The string containing text and potentially embedded JSON. + /// - Returns: An array of `ParsedA2AEvent`s representing the separated text and data parts. + private static func splitTextParts(_ textPart: String) -> [ParsedA2AEvent] { + var parts: [ParsedA2AEvent] = [] + if textPart.contains(a2uiJsonTagOpen) { + var remainingText = textPart + while let startRange = remainingText.range(of: a2uiJsonTagOpen) { + let intro = String(remainingText[.. (html: String, baseURL: URL?)? { + assert(Thread.isMainThread, "A2UIServices.getLocalHTMLContent() must be called from the main thread.") + let currentKey = self.apiKey ?? "" + // Return the cached HTML if we've already resolved it for the current API key + if let cached = self.cachedContent, self.cachedForApiKey == currentKey { + return (html: cached, baseURL: Bundle.module.resourceURL) + } + + guard let templateUrl = Bundle.module.url(forResource: "index", withExtension: "html") else { + logger.error("Failed to find index.html in GoogleMapsA2UI module bundle") + return nil + } + + guard let templateContent = try? String(contentsOf: templateUrl, encoding: .utf8) else { + logger.error("Failed to read local index.html content") + return nil + } + + // Inject the API key directly into the HTML string. + // This allows the Maps JavaScript API inside the web component to authenticate successfully. + let resolvedHtml = templateContent.replacingOccurrences( + of: "$GOOGLE_MAPS_API_KEY", + with: currentKey + ) + + self.cachedContent = resolvedHtml + self.cachedForApiKey = currentKey + + return (html: resolvedHtml, baseURL: Bundle.module.resourceURL) + } +} + diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift new file mode 100644 index 0000000..806c92d --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/A2UIView.swift @@ -0,0 +1,322 @@ +// +// 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 WebKit + +/// Main entry point for rendering GoogleMapsA2UI message parts. +/// Renders the parsed A2UI event using a WKWebView wrapper. +/// The WKWebView is used to securely execute the `a2ui-shell` web component, +/// which interprets the declarative JSON payload and renders interactive maps and UI elements. +public struct A2UIView: View { + private let part: ParsedA2AEvent + private let id: String + private let onUserAction: (String) -> Void + private let onRenderComplete: ((String, Double, String) -> Void)? + + /// Initializes the A2UIView. + /// - Parameters: + /// - part: The parsed A2A event containing the declarative UI payload. + /// - id: A unique identifier for this view or web component instance. + /// - onUserAction: Callback invoked when the user interacts with the UI (e.g., clicks a button). + /// - onRenderComplete: Optional callback invoked when rendering is complete. + public init( + part: ParsedA2AEvent, + id: String, + onUserAction: @escaping (String) -> Void, + onRenderComplete: ((String, Double, String) -> Void)? = nil + ) { + self.part = part + self.id = id + self.onUserAction = onUserAction + self.onRenderComplete = onRenderComplete + } + + public var body: some View { + if case let .data(payloadData, _) = part { + A2UIMessageInnerWrapper( + webViewID: id, + payload: payloadData, + onUserAction: onUserAction, + onRenderComplete: onRenderComplete + ) + } else { + EmptyView() + } + } +} + + +/// An internal wrapper View that manages the dynamic height state of the WKWebView +/// and applies standard styling like shadows and rounded corners to the message container. +struct A2UIMessageInnerWrapper: View { + let webViewID: String + let payload: Any + let onUserAction: (String) -> Void + let onRenderComplete: ((String, Double, String) -> Void)? + + @State private var height: CGFloat = 100 // Default height + + var body: some View { + A2UIMessageRepresentableView( + webViewID: webViewID, + payload: payload, + dynamicHeight: $height, + onUserAction: onUserAction, + onRenderComplete: onRenderComplete + ) + .frame(maxWidth: .infinity) + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) + } +} + +/// Internal UIViewRepresentable view to construct and manage the lifecycle of the WKWebView. +/// This acts as the bridge between SwiftUI and the underlying UIKit/WebKit components. +struct A2UIMessageRepresentableView: UIViewRepresentable { + let webViewID: String + let payload: Any + @Binding var dynamicHeight: CGFloat + let onUserAction: (String) -> Void + let onRenderComplete: ((String, Double, String) -> Void)? + + /// Creates the WKWebView instance and configures its bridge to the web component. + /// - Parameter context: The SwiftUI context. + /// - Returns: A configured WKWebView. + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let contentController = WKUserContentController() + + // Expose iOS bridge to JS (window.webkit.messageHandlers.iOS) + // This allows the web component to communicate user interactions (like "get_directions") back to Swift. + contentController.add(context.coordinator, name: "iOS") + + // Allows the JS ResizeObserver to notify Swift when the content height changes + contentController.add(context.coordinator, name: "heightObserver") + + // Inject a script to intercept console.log and console.error output from the WKWebView. + // This forwards JS logs to the native bridge, making it much easier to debug the web component in Xcode. + let consoleScriptSource = """ + const origLog = console.log; + console.log = function() { + origLog.apply(console, arguments); + var msg = Array.from(arguments).map(a => String(a)).join(' '); + window.webkit.messageHandlers.iOS.postMessage({action: 'log', data: msg}); + }; + const origError = console.error; + console.error = function() { + origError.apply(console, arguments); + var msg = Array.from(arguments).map(a => String(a)).join(' '); + window.webkit.messageHandlers.iOS.postMessage({action: 'error', data: msg}); + }; + window.addEventListener('error', function(e) { + window.webkit.messageHandlers.iOS.postMessage({action: 'error', data: 'Global Error: ' + e.message + ' at line ' + e.lineno}); + """ + let consoleScript = WKUserScript( + source: consoleScriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true) + contentController.addUserScript(consoleScript) + + config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + config.setValue(true, forKey: "allowUniversalAccessFromFileURLs") + config.userContentController = contentController + + // Enable HTML5 Fullscreen API + if #available(iOS 16.4, *) { + config.preferences.isElementFullscreenEnabled = true + } + + let webView = WKWebView(frame: UIScreen.main.bounds, configuration: config) + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.scrollView.isScrollEnabled = false // Prevent double scrolling inside the chat list + + // Fix for the gray background sometimes seen at the boundaries of WKWebViews. + // Setting the view and its scroll view to clear ensures our SwiftUI styling (shadows/corners) looks correct. + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + if #available(iOS 11.0, *) { + webView.scrollView.contentInsetAdjustmentBehavior = .never + } + + // Load local HTML resource content which contains the A2UI web components. + if let localContent = A2UIServices.getLocalHTMLContent() { + webView.loadHTMLString(localContent.html, baseURL: localContent.baseURL) + } else { + print("ERROR: Failed to load local HTML content from bundle") + } + + return webView + } + + /// Updates the WKWebView when SwiftUI state changes. + /// Injects the latest payload if the JavaScript context is ready. + /// - Parameters: + /// - uiView: The WKWebView instance to update. + /// - context: The SwiftUI context. + func updateUIView(_ uiView: WKWebView, context: Context) { + // If the view updates and JS is ready, push the JSON + if context.coordinator.isJSReady { + context.coordinator.injectJSON(uiView, payload: payload) + } + } + + /// Creates the coordinator that delegates WKWebView and JavaScript message handling. + /// - Returns: A new Coordinator instance. + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate { + var parent: A2UIMessageRepresentableView + var isJSReady = false + var lastInjectedPayload: String? + + /// Initializes the coordinator with a reference to its parent view. + /// - Parameter parent: The parent A2UIMessageRepresentableView. + init(_ parent: A2UIMessageRepresentableView) { + self.parent = parent + } + + /// Intercepts navigation actions to handle external links. + /// - Parameters: + /// - webView: The web view invoking the delegate method. + /// - navigationAction: Descriptive information about the action triggering the navigation request. + /// - decisionHandler: The closure to call to allow or cancel the navigation. + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url { + if let scheme = url.scheme, ["http", "https"].contains(scheme.lowercased()) { + UIApplication.shared.open(url) + decisionHandler(.cancel) + return + } + } + decisionHandler(.allow) + } + + /// Handles requests to open a new window. + /// - Parameters: + /// - webView: The web view invoking the delegate method. + /// - configuration: The configuration to use when creating the new web view. + /// - navigationAction: The navigation action causing the new web view to be created. + /// - windowFeatures: Window features requested by the webpage. + /// - Returns: A new web view, or `nil` if the request is handled natively. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url { + UIApplication.shared.open(url) + } + return nil + } + + /// Safely serializes and injects the JSON payload into the `a2ui-shell` web component. + /// - Parameters: + /// - webView: The web view hosting the component. + /// - payload: The dictionary payload to serialize and inject. + func injectJSON(_ webView: WKWebView, payload: Any) { + // Use JSONSerialization to safely escape the native Swift object for inclusion in JavaScript. + let jsonString: String + if let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []), + let str = String(data: jsonData, encoding: .utf8) { + jsonString = str + } else { + jsonString = "[]" + } + + if jsonString == lastInjectedPayload { return } + lastInjectedPayload = jsonString + + // Use https://developer.apple.com/documentation/foundation/jsonencoder to safely escape + // the JSON string as a JavaScript string literal. + let encodedString: String + if let encodedData = try? JSONEncoder().encode(jsonString), + let str = String(data: encodedData, encoding: .utf8) { + encodedString = str + } else { + encodedString = "\"[]\"" + } + + let script = """ + try { + const shell = document.querySelector('a2ui-shell'); + if (shell) { + console.log('iOS Native Bridge: Calling processA2uiMessages'); + shell.processA2uiMessages(\(encodedString)); + } else { + console.error('iOS Native Bridge: a2ui-shell not found'); + } + } catch (e) { + console.error('iOS WebKit Injection Error: ' + e.message); + } + """ + webView.evaluateJavaScript(script) + } + + /// Receives messages sent from JavaScript via the `window.webkit.messageHandlers` bridge. + /// Handles logging, errors, height observation, and custom user actions. + /// - Parameters: + /// - userContentController: The user content controller invoking the delegate method. + /// - message: The message received from the webpage. + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + if message.name == "heightObserver", let newHeight = message.body as? CGFloat { + if newHeight > 50 { + let targetHeight = newHeight + // Only update if difference > 5 to prevent infinite SwiftUI layout loops + if abs(parent.dynamicHeight - targetHeight) > 5 { + parent.dynamicHeight = targetHeight + parent.onRenderComplete?(parent.webViewID, 0.0, "success") + } + } + } else if message.name == "iOS", + let body = message.body as? [String: Any], + let action = body["action"] as? String, + let data = body["data"] as? String + { + switch action { + case "log": + print("JS LOG: \(data)") + case "error": + print("JS ERROR: \(data)") + case "onGetDirections": + parent.onUserAction(data) + case "onJsReady": + isJSReady = true + if let webView = message.webView { + injectJSON(webView, payload: parent.payload) + } + default: + break + } + } + } + } +} \ No newline at end of file diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift new file mode 100644 index 0000000..4ed0cf3 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Models.swift @@ -0,0 +1,41 @@ +// +// 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 + +/// Metadata associated with a message part. +public struct ParsedA2AEventMetadata { + public let mimeType: String? + + /// Initializes the metadata. + /// + /// - Parameter mimeType: The optional MIME type of the payload. + public init(mimeType: String? = nil) { + self.mimeType = mimeType + } +} + +/// Represents the parsed output chunk extracted from the raw server JSON. +public enum ParsedA2AEvent { + /// A conversational text block. + case text(String) + + /// A structured data payload to be fed into A2UIView. + /// When `metadata?.mimeType` is `"application/json+a2ui"`, the payload is guaranteed + /// to be an Array containing one or more A2UI JSON components (e.g., `[[String: Any]]`), + /// ensuring a consistent structure for downstream UI rendering. + case data(Any, metadata: ParsedA2AEventMetadata? = nil) +} diff --git a/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html new file mode 100644 index 0000000..b5c0f00 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Sources/GoogleMapsA2UI/Resources/index.html @@ -0,0 +1,1182 @@ + + + + + + + + AI Kit React Reference Implementation + + + + +
+ + \ No newline at end of file diff --git a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift new file mode 100644 index 0000000..67ae7ab --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift @@ -0,0 +1,322 @@ +// +// 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 +@testable import GoogleMapsA2UI + +final class A2AResponseParserTests: XCTestCase { + + /// Tests that an error is thrown when the input payload is not a valid JSON object. + func testParse_InvalidJSONFormat() { + let invalidPayload: [String: Any] = ["key": Date()] // Date is not valid JSON + XCTAssertThrowsError(try A2AResponseParser.parse(invalidPayload)) { error in + XCTAssertEqual(error as? A2AParserError, .invalidJSONFormat) + } + } + + /// Tests that an error is thrown when the JSON payload lacks a recognizable `parts` structure. + func testParse_InvalidPayloadStructure() { + let payloadWithNoParts: [String: Any] = ["status": "ok"] + XCTAssertThrowsError(try A2AResponseParser.parse(payloadWithNoParts)) { error in + XCTAssertEqual(error as? A2AParserError, .invalidPayloadStructure) + } + } + + /// Tests that a single text part is parsed correctly into a text event. + func testParse_SimpleTextPart() throws { + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": "Show me some good sushi in Seattle"] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + + guard case let .text(text) = events[0] else { + XCTFail("Expected text event") + return + } + XCTAssertEqual(text, "Show me some good sushi in Seattle") + } + + /// Tests that multiple text parts within the `content.parts` path are parsed into separate text events. + func testParse_MultipleTextParts() throws { + let payload: [String: Any] = [ + "content": [ + "parts": [ + ["kind": "text", "text": "Show me some good sushi in Seattle"], + ["kind": "text", "text": "What are their ratings?"] + ] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 2) + + if case let .text(text1) = events[0] { + XCTAssertEqual(text1, "Show me some good sushi in Seattle") + } else { + XCTFail("Expected first event to be text") + } + + if case let .text(text2) = events[1] { + XCTAssertEqual(text2, "What are their ratings?") + } else { + XCTFail("Expected second event to be text") + } + } + + /// Tests that an A2UI JSON payload embedded inside a text part using `` tags is extracted. + func testParse_EmbeddedA2UIJSON() throws { + let textWithJSON = "Here is the Seattle map {\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}} Hope you like it!" + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": textWithJSON] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 3) + + guard case let .text(prefix) = events[0] else { + return XCTFail("Expected text event") + } + XCTAssertEqual(prefix, "Here is the Seattle map") + + guard case let .data(data, metadata) = events[1] else { + return XCTFail("Expected data event") + } + XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") + let array = data as? [Any] + let dict = array?.first as? [String: Any] + XCTAssertNotNil(dict?["createSurface"]) + + guard case let .text(suffix) = events[2] else { + return XCTFail("Expected text event") + } + XCTAssertEqual(suffix, "Hope you like it!") + } + + /// Tests that a data part with an explicit A2UI mime type is parsed and batched into an array. + func testParse_DataPartWithA2UIMimeType() throws { + let payload: [String: Any] = [ + "parts": [ + [ + "kind": "data", + "data": [ + "version": "v0.9", + "updateComponents": [ + "surfaceId": "sushi-seattle", + "components": [] + ] + ], + "metadata": ["mimeType": "application/json+a2ui"] + ] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + + guard case let .data(data, metadata) = events[0] else { + return XCTFail("Expected data event") + } + XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") + + let a2uiArray = data as? [Any] + XCTAssertNotNil(a2uiArray, "A2UI payload should be batched into an array") + XCTAssertEqual(a2uiArray?.count, 1) + } + + /// Tests that a data part is inferred as A2UI if it contains recognized keys, even without a mime type. + func testParse_DataPartWithImplicitA2UIKey() throws { + let payload: [String: Any] = [ + "parts": [ + [ + "kind": "data", + "data": [ + "createSurface": [ + "surfaceId": "sushi-seattle", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + ] + ] + ] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + + guard case let .data(data, metadata) = events[0] else { + return XCTFail("Expected data event") + } + XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") + let a2uiArray = data as? [Any] + XCTAssertNotNil(a2uiArray) + XCTAssertEqual(a2uiArray?.count, 1) + } + + /// Tests that the parser can successfully locate and extract parts from the `status.message.parts` JSON path. + func testParse_StatusMessagePartsPath() throws { + let payload: [String: Any] = [ + "status": [ + "message": [ + "parts": [ + ["kind": "text", "text": "Seattle is home to a world-class sushi scene"] + ] + ] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + + guard case let .text(text) = events[0] else { + XCTFail("Expected text event") + return + } + XCTAssertEqual(text, "Seattle is home to a world-class sushi scene") + } + + /// Tests that consecutive data parts identified as A2UI payloads are batched together into a single data event. + func testParse_ConsecutiveA2UIPayloadsAreBatched() throws { + let payload: [String: Any] = [ + "parts": [ + [ + "kind": "data", + "data": [ + "createSurface": [ + "surfaceId": "sushi-seattle", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + ] + ], + "metadata": ["mimeType": "application/json+a2ui"] + ], + [ + "kind": "data", + "data": [ + "updateComponents": [ + "surfaceId": "sushi-seattle", + "components": [] + ] + ], + "metadata": ["mimeType": "application/json+a2ui"] + ] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + + guard case let .data(data, metadata) = events[0] else { + XCTFail("Expected data event") + return + } + XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") + + guard let a2uiArray = data as? [Any] else { + XCTFail("Expected data payload to be an array of batched items") + return + } + XCTAssertEqual(a2uiArray.count, 2) + + guard let dict1 = a2uiArray[0] as? [String: Any], + let dict2 = a2uiArray[1] as? [String: Any] else { + XCTFail("Expected array elements to be dictionaries") + return + } + XCTAssertNotNil(dict1["createSurface"]) + XCTAssertNotNil(dict2["updateComponents"]) + } + + /// Tests that an A2UI batch is finalized and a new one starts if interrupted by a text part. + func testParse_A2UIBatchInterruptedByTextPart() throws { + let payload: [String: Any] = [ + "parts": [ + [ + "kind": "data", + "data": ["createSurface": ["surfaceId": "sushi-seattle"]], + "metadata": ["mimeType": "application/json+a2ui"] + ], + ["kind": "text", "text": "Middle Text explaining the surface"], + [ + "kind": "data", + "data": ["updateComponents": ["surfaceId": "sushi-seattle"]], + "metadata": ["mimeType": "application/json+a2ui"] + ] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 3) + + guard case let .data(data1, metadata1) = events[0] else { + XCTFail("Expected first event to be data") + return + } + XCTAssertEqual(metadata1?.mimeType, "application/json+a2ui") + let batch1 = data1 as? [Any] + XCTAssertEqual(batch1?.count, 1) + + guard case let .text(text) = events[1] else { + XCTFail("Expected second event to be text") + return + } + XCTAssertEqual(text, "Middle Text explaining the surface") + + guard case let .data(data2, metadata2) = events[2] else { + XCTFail("Expected third event to be data") + return + } + XCTAssertEqual(metadata2?.mimeType, "application/json+a2ui") + let batch2 = data2 as? [Any] + XCTAssertEqual(batch2?.count, 1) + } + + /// Tests that multiple `` tags within a single text part are all extracted sequentially. + func testParse_MultipleEmbeddedA2UITags() throws { + let textWithMultipleTags = "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": textWithMultipleTags] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 5) + + guard case let .text(t1) = events[0], + case let .data(d1, m1) = events[1], + case let .text(t2) = events[2], + case let .data(d2, m2) = events[3], + case let .text(t3) = events[4] else { + XCTFail("Expected sequence: [text, data, text, data, text]") + return + } + + XCTAssertEqual(t1, "First map:") + XCTAssertEqual(m1?.mimeType, "application/json+a2ui") + XCTAssertNotNil((d1 as? [Any])?.first as? [String: Any]) + + XCTAssertEqual(t2, "Then:") + XCTAssertEqual(m2?.mimeType, "application/json+a2ui") + XCTAssertNotNil((d2 as? [Any])?.first as? [String: Any]) + + XCTAssertEqual(t3, "Done.") + } +} + diff --git a/client/ios/README.md b/client/ios/README.md new file mode 100644 index 0000000..4617581 --- /dev/null +++ b/client/ios/README.md @@ -0,0 +1,137 @@ +# GoogleMapsA2UI iOS Library + +> **Note:** This toolkit is in **Experimental** status. + +The `GoogleMapsA2UI` library is an iOS library designed to encapsulate the parsing and rendering of Maps Agent-to-UI (A2UI) payloads. It seamlessly converts complex A2A JSON responses into native-friendly, rich map interfaces using SwiftUI and WKWebView. + +It makes use of the following technologies: +* [Google Maps Platform](https://mapsplatform.google.com/) for rendering maps and places. +* [A2UI](https://a2ui.org/) for the Agent-driven dynamic UI protocol. +* [SwiftUI](https://developer.apple.com/xcode/swiftui/) for the native UI layer. +* `WKWebView` for rendering the web-based A2UI components securely. + +## Quickstart Guide + +To quickly get started, we recommend using the iOS sample project located in the [GoogleMapsA2UI Samples repository](https://github.com/googlemaps-samples/a2ui). This sample project demonstrates how to connect to the Python Agent and use the library to render chat bubbles and map interfaces. + +### Prerequisites and Tool Setup + +* **Protocol Version:** This library is built based on the **v0.9 A2UI protocol** and is **not backward compatible** with the v0.8 protocol. Ensure your backend server uses the v0.9 protocol format. +* **Xcode:** Xcode 15 or later (requires Swift 5.9+). +* **iOS Target:** iOS 16.0 or later. +* **Google Maps API Key:** You must have a Google Maps Platform API key to render the maps inside the A2UI web views. You can create one and enable the Maps JavaScript API in the [Google Cloud Console](https://mapsplatform.google.com/). + +--- + +## `GoogleMapsA2UI` iOS Library Package + +This package provides the core iOS components for the Maps Agentic UI Toolkit. It handles parsing standard A2A backend responses and rendering the dynamic map payloads. + +### File Structure / Architecture + +* **`A2AResponseParser`**: A utility that standardizes backend responses into an array of `ParsedA2AEvent` objects. +* **`ParsedA2AEvent`**: An enum representing an extracted payload chunk (`.text` or `.data`). +* **Single-File Web Bundle (`index.html`)**: The library uses a compiled, single-file HTML bundle. This bundle executes a lightweight React application that orchestrates the rendering of the underlying Lit web components. +* **`A2UIView`**: A custom SwiftUI view that wraps an underlying `WKWebView`. It mounts the single-file web bundle, dynamically resizes to fit the rendered content, and bridges native JavaScript callbacks back to Swift. +* **`A2UIServices`**: A global configuration enum used to provide the Maps API key. It efficiently reads the local web bundle, injects your API key directly into the HTML string, and caches the result for high-performance rendering. + +### How to Integrate + +> **Note** Because `Package.swift` is not located at the root of this repository, you cannot add the package directly via its Git URL. You must **clone the repository locally** first, then add the package using its local path. + +The library can be installed via Swift Package Manager (SPM). + +#### Using Xcode +1. Clone the repository to your local machine. +2. In Xcode, select **File > Add Package Dependencies...** +3. Click **Add Local...** and select the cloned `a2ui/client/ios/GoogleMapsA2UI` directory. +4. Add the `GoogleMapsA2UI` library to your app's target. + +#### Using Package.swift +If you are building your own Swift Package or using a modular architecture, add the package to your `Package.swift` dependencies: + +```swift +dependencies: [ + // Replace with actual local path + .package(path: "path/to/a2ui/client/ios/GoogleMapsA2UI") +] +``` + +### Usage + +#### 1. Global Initialization +Call `A2UIServices.provideApiKey()` at application startup to configure your Google Maps API Key. + +```swift +import SwiftUI +import GoogleMapsA2UI + +@main +struct MyApp: App { + init() { + A2UIServices.provideApiKey("YOUR_GOOGLE_MAPS_API_KEY") + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +#### 2. Parse the Response +Use the `A2AResponseParser` to safely extract payloads from the raw backend JSON tree into an array of strictly typed events. + +The parser searches for the message parts array at the following paths within the dictionary you provide: +* `rawJSON["parts"]` +* `rawJSON["content"]["parts"]` +* `rawJSON["status"]["message"]["parts"]` + +> **Note:** If your server wraps the A2A response inside a custom envelope or protocol, ensure to strip the outer wrapper and pass only the inner A2A payload to the parser so it can find the `parts` array at one of the paths above. + +```swift +import GoogleMapsA2UI + +// Extract standard JSON into A2A events +let parsedParts = (try? A2AResponseParser.parse(rawServerJson)) ?? [] +``` + +#### 3. Render the UI +Feed the parsed events into your SwiftUI layout. The library provides an `A2UIView` that automatically renders rich map interfaces for `.data` events containing an A2UI payload. + +```swift +import SwiftUI +import GoogleMapsA2UI + +struct ChatMessageView: View { + let parsedParts: [ParsedA2AEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(parsedParts.enumerated()), id: \.offset) { index, part in + switch part { + case .text(let text): + // Render plain conversational text in your native chat bubbles + Text(text) + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + + case .data(_, let metadata): + if metadata?.mimeType == "application/json+a2ui" { + // Let the library render the rich map UI + A2UIView( + part: part, + id: "unique-message-id-\(index)", + onUserAction: { actionData in + print("User interacted with the map: \(actionData)") + } + ) + } + } + } + } + } +} +``` diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 2c64e94..c5c20c0 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -95,7 +95,6 @@ "resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.3.tgz", "integrity": "sha512-ni/aK2oeBcjEESTO+XE+CidDb0N4aOzYL14XSYBAdAH2E7jmsbuUyHEKf4FQyYK0f8AA0C5thkZ09qPV2C3ikA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "dompurify": "^3.3.1", "markdown-it": "^14.1.0" @@ -109,7 +108,6 @@ "resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.9.2.tgz", "integrity": "sha512-EOfhLOF7tnpPmNq4y116k3gxWdrXQW8h3dhKF0pC++21zLZnCSLSHl6zgQFG+kPeVAZb64t+sQiRXlnyS8+RBg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@preact/signals-core": "^1.13.0", "date-fns": "^4.1.0", @@ -1123,8 +1121,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/signal-utils": { "version": "0.21.1", @@ -1328,7 +1325,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/client/web/src/lit/custom-components/google_map.ts b/client/web/src/lit/custom-components/google_map.ts index 3971bdf..e2d452d 100644 --- a/client/web/src/lit/custom-components/google_map.ts +++ b/client/web/src/lit/custom-components/google_map.ts @@ -330,13 +330,13 @@ export class GoogleMap extends A2uiLitElement { max-tilt=${mode === 'roadmap' ? '0' : nothing} heading="${heading}" map-id="2d6e1a27a57efe3c9479f6fc" - internal-usage-attribution-ids="gmp_web_maui_v0.1.7_exp" + internal-usage-attribution-ids="${(window as any).A2UI_ATTRIBUTION_ID || 'gmp_web_maui_v0.1.7_exp'}" >${routes.map((route: any) => html` `)} diff --git a/client/web/src/lit/custom-components/place_card.ts b/client/web/src/lit/custom-components/place_card.ts index 782fa4e..695ea61 100644 --- a/client/web/src/lit/custom-components/place_card.ts +++ b/client/web/src/lit/custom-components/place_card.ts @@ -79,7 +79,7 @@ export class PlaceCard extends A2uiLitElement {
+ internal-usage-attribution-ids="${(window as any).A2UI_ATTRIBUTION_ID || 'gmp_web_maui_v0.1.7_exp'}">