+
+
+
+
+ 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
+
+
+
+## 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'}">