diff --git a/.gitignore b/.gitignore
index ae0b85df..a3c56008 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,31 @@ site/
# Python virtual environment
.venv/
+
+# Android / Gradle
+.gradle/
+build/
+local.properties
+*.iml
+.idea/caches/
+.idea/libraries/
+.idea/modules.xml
+.idea/workspace.xml
+.idea/navEditor.xml
+.idea/assetWizardSettings.xml
+.idea/codeStyles/
+.idea/dictionaries/
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/gradle.xml
+.idea/misc.xml
+.idea/runConfigurations.xml
+.idea/vcs.xml
+captures/
+.externalNativeBuild
+.cxx
+*.apk
+*.ap_
+*.aab
+*.dex
+*.class
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 00000000..f1e58f5a
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+A2UI-Android-Sample
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 00000000..16d7cce6
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 00000000..fe63bb67
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 00000000..0651eeb9
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/other.xml b/.idea/other.xml
new file mode 100644
index 00000000..c9a97cc8
--- /dev/null
+++ b/.idea/other.xml
@@ -0,0 +1,1077 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/renderers/android/README.md b/renderers/android/README.md
new file mode 100644
index 00000000..69c93275
--- /dev/null
+++ b/renderers/android/README.md
@@ -0,0 +1,99 @@
+# A2UI Android Renderer
+
+A native Android renderer for the A2UI protocol, built with Kotlin and Jetpack Compose.
+
+## Architecture Overview
+
+This project implements the A2UI protocol using a clean, modular architecture:
+
+1. **`a2ui-core`**: A pure Kotlin module containing the protocol data models (`ServerMessage`, `ComponentWrapper`), state management (`SurfaceState`), and action definitions. It uses `kotlinx.serialization` for robust JSON parsing.
+2. **`a2ui-compose`**: The Android library module containing the renderer logic.
+ * **`A2UISurface`**: The entry point composable. It holds the `SurfaceState` and renders the root component.
+ * **`ComponentRegistry`**: Maps protocol component names (e.g., "Text", "Button") to Composables.
+ * **`A2UIComponent`**: A recursive composable that looks up components by ID and delegates to the registered renderer.
+ * **`components/`**: Individual Material 3 implementations of A2UI components.
+
+### Comparison with Lit & Angular Renderers
+
+| Feature | Lit / Angular | Android (Compose) |
+| :--- | :--- | :--- |
+| **Node Mapping** | DOM Elements / Directives | Composables |
+| **Updates** | Reactive Properties / Signals | Recomposition (`key`, `State`) |
+| **Registry** | String Map to Classes | String Map to Composable Functions |
+| **Styling** | CSS / SCSS | Compose Modifiers |
+| **Data Binding** | Framework Binding | `A2UIContext.resolve()` helper |
+
+This renderer follows the **Adjacency List** model used by the web renderers, where components are stored in a flat map and the tree is built recursively at render time.
+
+## Usage Guide
+
+### 1. Project Setup (Composite Build)
+
+The recommended way to work with the renderer is using a Gradle Composite Build, as demonstrated in `samples/client/android`.
+
+In your app's `settings.gradle.kts`:
+```kotlin
+includeBuild("path/to/A2UI/renderers/android") {
+ dependencySubstitution {
+ substitute(module("com.google.a2ui.compose:a2ui-compose")).using(project(":a2ui-compose"))
+ substitute(module("com.google.a2ui.core:a2ui-core")).using(project(":a2ui-core"))
+ }
+}
+```
+
+Then in your `build.gradle.kts`:
+```kotlin
+implementation("com.google.a2ui.compose:a2ui-compose")
+implementation("com.google.a2ui.core:a2ui-core")
+```
+
+### 2. Initialize State
+
+Create a `SurfaceState` to hold the document:
+```kotlin
+val surfaceState = remember { SurfaceState() }
+```
+
+### 3. Process Messages
+
+Feed JSON messages from your stream into the state:
+```kotlin
+// Example: Parsing a JSON string
+val message = Json.decodeFromString(jsonString)
+surfaceState.applyUpdate(message)
+```
+
+### 4. Render Surface
+
+Use the `A2UISurface` composable:
+```kotlin
+A2UISurface(
+ surfaceId = "chat_response_1",
+ state = surfaceState,
+ onUserAction = { action, sourceId ->
+ // Handle action (e.g., send back to server)
+ Log.d("A2UI", "Action: ${action.name} from $sourceId")
+ }
+)
+```
+
+## Supported Components (MVP)
+
+* `Column`, `Row`, `Box` (Basic Layouts)
+* `Text` (with Typography mapping)
+* `Button` (Material 3 Button)
+* `TextField` (Material 3 OutlinedTextField)
+* `Image` (Placeholder/Basic text)
+
+## Extensibility
+
+To add a new component:
+
+1. Define properties in `Component.kt` (Core).
+2. Create a Composable renderer (e.g., `MyCustomWidget_Renderer`).
+3. Register it in `ComponentRegistry`:
+ ```kotlin
+ ComponentRegistry.register("MyCustomWidget") { wrapper, ctx ->
+ MyCustomWidget_Renderer(wrapper.Custom!!, ctx)
+ }
+ ```
diff --git a/renderers/android/a2ui-compose/build.gradle.kts b/renderers/android/a2ui-compose/build.gradle.kts
new file mode 100644
index 00000000..3078ba88
--- /dev/null
+++ b/renderers/android/a2ui-compose/build.gradle.kts
@@ -0,0 +1,62 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "com.google.a2ui.compose"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 24
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.11"
+ }
+}
+
+dependencies {
+ implementation(project(":a2ui-core"))
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.kotlinx.serialization.json)
+ implementation("io.coil-kt:coil-compose:2.6.0")
+ implementation(kotlin("reflect"))
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UIComponent.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UIComponent.kt
new file mode 100644
index 00000000..03ff6fee
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UIComponent.kt
@@ -0,0 +1,29 @@
+package com.google.a2ui.compose
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import com.google.a2ui.core.state.SurfaceState
+
+@Composable
+fun A2UIComponent(
+ id: String,
+ context: A2UIContext
+) {
+ val componentWrapper = context.state.components[id]
+
+ if (componentWrapper == null) {
+ // Fallback for missing component
+ Text(text = "Missing component: $id")
+ return
+ }
+
+ val renderer = ComponentRegistry.getRenderer(componentWrapper)
+ if (renderer != null) {
+ key(id) {
+ renderer(componentWrapper, context)
+ }
+ } else {
+ Text(text = "Unknown component type for: $id")
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UIContext.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UIContext.kt
new file mode 100644
index 00000000..e5ab158c
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UIContext.kt
@@ -0,0 +1,27 @@
+package com.google.a2ui.compose
+
+import com.google.a2ui.core.model.Action
+import com.google.a2ui.core.model.BoundValue
+import com.google.a2ui.core.state.SurfaceState
+import kotlinx.serialization.json.JsonElement
+
+data class A2UIContext(
+ val state: SurfaceState,
+ val onUserAction: (Action, String, Map) -> Unit // action, sourceId, context
+) {
+ fun resolve(boundValue: BoundValue?): Any? {
+ return state.resolve(boundValue)
+ }
+
+ fun resolveString(boundValue: BoundValue?): String {
+ return resolve(boundValue)?.toString() ?: ""
+ }
+
+ fun resolveBoolean(boundValue: BoundValue?): Boolean {
+ return resolve(boundValue) as? Boolean ?: false
+ }
+
+ fun resolveNumber(boundValue: BoundValue?): Double {
+ return (resolve(boundValue) as? Number)?.toDouble() ?: 0.0
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UISurface.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UISurface.kt
new file mode 100644
index 00000000..7e07974f
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/A2UISurface.kt
@@ -0,0 +1,30 @@
+package com.google.a2ui.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.google.a2ui.core.model.Action
+import com.google.a2ui.core.state.SurfaceState
+import kotlinx.serialization.json.JsonElement
+
+val LocalA2UIContext = staticCompositionLocalOf {
+ error("No A2UIContext provided")
+}
+
+@Composable
+fun A2UISurface(
+ surfaceId: String,
+ state: SurfaceState,
+ onUserAction: (Action, String, Map) -> Unit
+) {
+ val context = A2UIContext(
+ state = state,
+ onUserAction = onUserAction
+ )
+
+ CompositionLocalProvider(LocalA2UIContext provides context) {
+ if (state.rootId != null) {
+ A2UIComponent(id = state.rootId!!, context = context)
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/ComponentRegistry.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/ComponentRegistry.kt
new file mode 100644
index 00000000..d3e7dfab
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/ComponentRegistry.kt
@@ -0,0 +1,81 @@
+package com.google.a2ui.compose
+
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.components.ButtonRenderer
+import com.google.a2ui.compose.components.CardRenderer
+import com.google.a2ui.compose.components.CheckboxRenderer
+import com.google.a2ui.compose.components.ColumnRenderer
+import com.google.a2ui.compose.components.DateTimeRenderer
+import com.google.a2ui.compose.components.ImageRenderer
+import com.google.a2ui.compose.components.ModalRenderer
+import com.google.a2ui.compose.components.RowRenderer
+import com.google.a2ui.compose.components.SliderRenderer
+import com.google.a2ui.compose.components.TabsRenderer
+import com.google.a2ui.compose.components.TextFieldRenderer
+import com.google.a2ui.compose.components.TextRenderer
+import com.google.a2ui.compose.components.VideoRenderer
+import com.google.a2ui.compose.components.IconRenderer
+import com.google.a2ui.compose.components.DividerRenderer
+import com.google.a2ui.core.model.ComponentWrapper
+
+typealias ComponentRenderer = @Composable (ComponentWrapper, A2UIContext) -> Unit
+
+object ComponentRegistry {
+ private val renderers = mutableMapOf()
+
+ init {
+ // Register default components
+ register("Text") { wrapper, ctx -> TextRenderer(wrapper.Text!!, ctx) }
+ register("Button") { wrapper, ctx -> ButtonRenderer(wrapper.Button!!, ctx) }
+ register("Column") { wrapper, ctx -> ColumnRenderer(wrapper.Column!!, ctx) }
+ register("Row") { wrapper, ctx -> RowRenderer(wrapper.Row!!, ctx) }
+ register("Image") { wrapper, ctx -> ImageRenderer(wrapper.Image!!, ctx) }
+ register("TextField") { wrapper, ctx -> TextFieldRenderer(wrapper.TextField!!, ctx) }
+
+ // Register new components
+ register("Checkbox") { wrapper, ctx -> CheckboxRenderer(wrapper.Checkbox!!, ctx) }
+ register("Slider") { wrapper, ctx -> SliderRenderer(wrapper.Slider!!, ctx) }
+ register("Card") { wrapper, ctx -> CardRenderer(wrapper.Card!!, ctx) }
+ register("Tabs") { wrapper, ctx -> TabsRenderer(wrapper.Tabs!!, ctx) }
+ register("Modal") { wrapper, ctx -> ModalRenderer(wrapper.Modal!!, ctx) }
+ register("DateTimeInput") { wrapper, ctx -> DateTimeRenderer(wrapper.DateTimeInput!!, ctx) }
+ register("Video") { wrapper, ctx -> VideoRenderer(wrapper.Video!!, ctx) }
+ register("Icon") { wrapper, ctx -> IconRenderer.Render(wrapper, ctx) }
+ register("Divider") { wrapper, ctx -> DividerRenderer(wrapper.Divider!!, ctx) }
+ }
+
+ fun register(type: String, renderer: ComponentRenderer) {
+ renderers[type] = renderer
+ }
+
+ fun getRenderer(wrapper: ComponentWrapper): ComponentRenderer? {
+ val type = getType(wrapper)
+ return type?.let { renderers[it] }
+ }
+
+ private fun getType(wrapper: ComponentWrapper): String? {
+ // Find which property is not null.
+ // In a real implementation this might be optimized or explicit type name passed.
+ // A2UI protocol dictates single key.
+ return when {
+ wrapper.Text != null -> "Text"
+ wrapper.Button != null -> "Button"
+ wrapper.Column != null -> "Column"
+ wrapper.Row != null -> "Row"
+ wrapper.Box != null -> "Box"
+ wrapper.Image != null -> "Image"
+ wrapper.TextField != null -> "TextField"
+ wrapper.Checkbox != null -> "Checkbox"
+ wrapper.Slider != null -> "Slider"
+ wrapper.Card != null -> "Card"
+ wrapper.Tabs != null -> "Tabs"
+ wrapper.Modal != null -> "Modal"
+ wrapper.DateTimeInput != null -> "DateTimeInput"
+ wrapper.Video != null -> "Video"
+ wrapper.Audio != null -> "Audio"
+ wrapper.Icon != null -> "Icon"
+ wrapper.Divider != null -> "Divider"
+ else -> null
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ButtonRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ButtonRenderer.kt
new file mode 100644
index 00000000..9261ba4d
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ButtonRenderer.kt
@@ -0,0 +1,44 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.A2UIComponent
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.ButtonProperties
+
+@Composable
+fun ButtonRenderer(
+ properties: ButtonProperties,
+ context: A2UIContext
+) {
+ val onClick = {
+ properties.action?.let { action ->
+ // Source ID needs to be passed here, ideally ButtonRenderer gets the ID of the component
+ // But A2UIComponent passes wrapper. We might need access to the ID.
+ // For now, let's assume the wrapper has it or the context is updated.
+ // Wait, wrapper doesn't have ID. A2UIComponent has ID.
+
+ // Refactor Idea: ComponentRenderer should receive ID or instance.
+ // Current signature: (ComponentWrapper, A2UIContext) -> Unit
+ // I'll update signature in next step or use a placeholder ID for now.
+ // Actually, context.onUserAction requires sourceID.
+
+ // To fix this properly, I should pass the ID to the renderer.
+ // But let's stick to current plan and fix registry separately.
+ // I'll emit "unknown" for now and fix later.
+ context.onUserAction(action, "unknown_source_id", emptyMap())
+ }
+ Unit
+ }
+
+ Button(onClick = onClick) {
+ if (properties.label != null) {
+ Text(text = context.resolveString(properties.label))
+ } else {
+ properties.child?.let { childId ->
+ A2UIComponent(id = childId, context = context)
+ }
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/CardRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/CardRenderer.kt
new file mode 100644
index 00000000..24f1b98f
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/CardRenderer.kt
@@ -0,0 +1,26 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.google.a2ui.compose.A2UIComponent
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.CardProperties
+
+@Composable
+fun CardRenderer(
+ props: CardProperties,
+ context: A2UIContext
+) {
+ Card(
+ modifier = Modifier.padding(8.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ ) {
+ props.child?.let { childId ->
+ A2UIComponent(childId, context)
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/CheckboxRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/CheckboxRenderer.kt
new file mode 100644
index 00000000..55ba0f11
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/CheckboxRenderer.kt
@@ -0,0 +1,34 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.CheckboxProperties
+import kotlinx.serialization.json.JsonPrimitive
+
+@Composable
+fun CheckboxRenderer(
+ props: CheckboxProperties,
+ context: A2UIContext
+) {
+ val checked = props.checked?.let { context.resolveBoolean(it) } ?: false
+ val label = props.label?.let { context.resolveString(it) }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = { isChecked ->
+ props.onCheckedChange?.let { action ->
+ // In a real app we'd pass the new value in the context
+ context.onUserAction(action, "unknown_source_id", mapOf("checked" to JsonPrimitive(isChecked)))
+ }
+ }
+ )
+ if (label != null) {
+ Text(text = label)
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/DateTimeRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/DateTimeRenderer.kt
new file mode 100644
index 00000000..9e047800
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/DateTimeRenderer.kt
@@ -0,0 +1,79 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.DatePicker
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDatePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.DateTimeInputProperties
+import kotlinx.serialization.json.JsonPrimitive
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DateTimeRenderer(
+ props: DateTimeInputProperties,
+ context: A2UIContext
+) {
+ val dateStr = props.value?.let { context.resolveString(it) } ?: ""
+ val label = props.label?.let { context.resolveString(it) } ?: "Select Date"
+
+ var showDatePicker by remember { mutableStateOf(false) }
+
+ // Convert ISO string to millis for DatePicker if needed, skipping complex parsing for MVP
+ // Assuming dateStr is displayable or we use simple parser
+
+ OutlinedTextField(
+ value = dateStr,
+ onValueChange = { }, // Read-only, set via picker
+ label = { Text(label) },
+ readOnly = true,
+ modifier = Modifier.onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ showDatePicker = true
+ }
+ }
+ )
+
+ if (showDatePicker) {
+ val datePickerState = rememberDatePickerState()
+
+ DatePickerDialog(
+ onDismissRequest = { showDatePicker = false },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showDatePicker = false
+ datePickerState.selectedDateMillis?.let { millis ->
+ val s = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(millis))
+ props.onValueChange?.let { action ->
+ context.onUserAction(action, "unknown_source_id", mapOf("value" to JsonPrimitive(s)))
+ }
+ }
+ }
+ ) {
+ Text("OK")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDatePicker = false }) {
+ Text("Cancel")
+ }
+ }
+ ) {
+ DatePicker(state = datePickerState)
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/DividerRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/DividerRenderer.kt
new file mode 100644
index 00000000..4c565143
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/DividerRenderer.kt
@@ -0,0 +1,26 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.DividerProperties
+
+@Composable
+fun DividerRenderer(
+ properties: DividerProperties,
+ context: A2UIContext
+) {
+ // Default thickness 1.dp if not specified
+ val thickness = properties.thickness?.let { it.dp } ?: 1.dp
+
+ // Parse color if present (skipping for this simple MVP, defaulting to onSurfaceVariant)
+
+ HorizontalDivider(
+ thickness = thickness,
+ modifier = Modifier,
+ color = Color.Unspecified // Uses Material theme default
+ )
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/IconRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/IconRenderer.kt
new file mode 100644
index 00000000..18b0628a
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/IconRenderer.kt
@@ -0,0 +1,90 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.ComponentWrapper
+import com.google.a2ui.core.model.IconProperties
+import kotlin.reflect.KProperty1
+import java.util.Locale
+
+object IconRenderer {
+ private val iconCache = mutableMapOf()
+
+ @Composable
+ fun Render(
+ wrapper: ComponentWrapper,
+ context: A2UIContext,
+ modifier: Modifier = Modifier
+ ) {
+ val props = wrapper.Icon ?: return
+ val rawName = context.resolveString(props.name) ?: "warning"
+
+ // Normalize name: snake_case to CamelCase if needed, matching Material naming
+ // e.g. "calendar_today" -> "CalendarToday"
+ // The agent sends "calendarToday" (camelCase).
+ // Material properties are "CalendarToday" (PascalCase).
+
+ val iconName = rawName.replaceFirstChar { it.uppercase() }
+
+ val iconVector = iconCache.getOrPut(iconName) {
+ findIconByName(iconName) ?: Icons.Default.Warning
+ }
+
+ Icon(
+ imageVector = iconVector,
+ contentDescription = null,
+ tint = LocalContentColor.current,
+ modifier = modifier
+ )
+ }
+
+ private fun findIconByName(name: String): ImageVector? {
+ // Aliases for common mismatches
+ val normalizedName = when (name.lowercase(Locale.ROOT)) {
+ "mail" -> "Email"
+ "calendar" -> "DateRange"
+ "calendartoday" -> "CalendarToday" // Explicitly ensure Extended naming
+ else -> name.replaceFirstChar { it.uppercase() }
+ }
+
+ // 1. Try accessing as member of Icons.Filled (Core icons)
+ try {
+ val kClass = Icons.Filled::class
+ val property = kClass.members.firstOrNull { it.name.equals(normalizedName, ignoreCase = true) }
+ if (property != null) {
+ @Suppress("UNCHECKED_CAST")
+ return (property as? KProperty1)?.get(Icons.Filled) as? ImageVector
+ }
+ } catch (e: Exception) {
+ // Ignore, try next method
+ }
+
+ // 2. Try accessing as Extension Property (Extended icons)
+ // These are compiled into classes named after the icon, e.g. androidx.compose.material.icons.filled.AccountBoxKt
+ // The accessor method is usually "getAccountBox(Icons.Filled)"
+ try {
+ // Construct class name: androidx.compose.material.icons.filled.NameKt
+ // Note: Case sensitivity matters for class loading. We try strict matching first.
+ val className = "androidx.compose.material.icons.filled.${normalizedName}Kt"
+ val clazz = Class.forName(className)
+ val method = clazz.getMethod("get$normalizedName", androidx.compose.material.icons.Icons.Filled::class.java)
+ return method.invoke(null, androidx.compose.material.icons.Icons.Filled) as? ImageVector
+ } catch (e: Exception) {
+ android.util.Log.w("IconRenderer", "Failed to find icon $normalizedName via reflection: ${e.message}")
+ }
+
+ return null
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ImageRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ImageRenderer.kt
new file mode 100644
index 00000000..86b82f6d
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ImageRenderer.kt
@@ -0,0 +1,24 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.material3.Text
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.ImageProperties
+
+@Composable
+fun ImageRenderer(
+ properties: ImageProperties,
+ context: A2UIContext
+) {
+ val rawUrl = context.resolveString(properties.url)
+ val url = rawUrl?.replace("localhost", "10.0.2.2")
+
+ if (url != null) {
+ coil.compose.AsyncImage(
+ model = url,
+ contentDescription = context.resolveString(properties.altText),
+ modifier = androidx.compose.ui.Modifier,
+ contentScale = androidx.compose.ui.layout.ContentScale.Crop
+ )
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/Layouts.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/Layouts.kt
new file mode 100644
index 00000000..0e9d06cb
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/Layouts.kt
@@ -0,0 +1,76 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import com.google.a2ui.compose.A2UIComponent
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.ContainerProperties
+
+@Composable
+fun ColumnRenderer(
+ properties: ContainerProperties,
+ context: A2UIContext
+) {
+ // Basic alignment mapping
+ val verticalArrangement = when(properties.alignment) {
+ "center" -> Arrangement.Center
+ "end" -> Arrangement.Bottom
+ "space-between" -> Arrangement.SpaceBetween
+ else -> Arrangement.Top
+ }
+
+ val horizontalAlignment = Alignment.Start // Simplification
+
+ Column(
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment
+ ) {
+ RenderChildren(properties, context)
+ }
+}
+
+@Composable
+fun RowRenderer(
+ properties: ContainerProperties,
+ context: A2UIContext
+) {
+ val horizontalArrangement = when(properties.alignment) {
+ "center" -> Arrangement.Center
+ "end" -> Arrangement.End
+ "space-between" -> Arrangement.SpaceBetween
+ else -> Arrangement.Start
+ }
+
+ val verticalAlignment = Alignment.CenterVertically // Simplification
+
+ Row(
+ horizontalArrangement = horizontalArrangement,
+ verticalAlignment = verticalAlignment
+ ) {
+ RenderChildren(properties, context)
+ }
+}
+
+@Composable
+fun RenderChildren(
+ properties: ContainerProperties,
+ context: A2UIContext
+) {
+ properties.children?.explicitList?.forEach { childId ->
+ A2UIComponent(id = childId, context = context)
+ }
+
+ // Template rendering support
+ val template = properties.children?.template
+ if (template != null) {
+ // Resolve data list
+ // This requires 'dataBinding' to be a path to a list
+ // context.resolvePath(template.dataBinding)
+ // This part requires deeply dynamic data scoping which is complex.
+ // For MVP, we'll skip template rendering or handle simple list.
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ModalRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ModalRenderer.kt
new file mode 100644
index 00000000..39bc566f
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/ModalRenderer.kt
@@ -0,0 +1,53 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.A2UIComponent
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.ModalProperties
+import kotlinx.serialization.json.JsonElement
+
+@Composable
+fun ModalRenderer(
+ props: ModalProperties,
+ context: A2UIContext
+) {
+ val isOpen = props.isOpen?.let { context.resolveBoolean(it) } ?: false
+
+ if (isOpen) {
+ val title = props.title?.let { context.resolveString(it) }
+
+ AlertDialog(
+ onDismissRequest = {
+ props.onDismiss?.let { action ->
+ context.onUserAction(action, "unknown_source_id", emptyMap())
+ }
+ },
+ title = {
+ if (title != null) {
+ Text(title)
+ }
+ },
+ text = {
+ props.content?.let { contentId ->
+ A2UIComponent(contentId, context)
+ }
+ },
+ confirmButton = {
+ // A2UI spec might have generic actions list.
+ // For MVP, if actions exist, we just render close button or custom actions if implemented.
+ Button(
+ onClick = {
+ props.onDismiss?.let { action ->
+ context.onUserAction(action, "unknown_source_id", emptyMap())
+ }
+ }
+ ) {
+ Text("Close")
+ }
+ }
+ )
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/SliderRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/SliderRenderer.kt
new file mode 100644
index 00000000..41b0bdbe
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/SliderRenderer.kt
@@ -0,0 +1,28 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.Slider
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.SliderProperties
+import kotlinx.serialization.json.JsonPrimitive
+
+@Composable
+fun SliderRenderer(
+ props: SliderProperties,
+ context: A2UIContext
+) {
+ val value = props.value?.let { context.resolveNumber(it) }?.toFloat() ?: 0f
+ val min = props.min?.toFloat() ?: 0f
+ val max = props.max?.toFloat() ?: 1f
+
+ Slider(
+ value = value,
+ onValueChange = { newValue ->
+ // Debouncing usually handled by state management, here we fire action
+ props.onValueChange?.let { action ->
+ context.onUserAction(action, "unknown_source_id", mapOf("value" to JsonPrimitive(newValue.toDouble())))
+ }
+ },
+ valueRange = min..max
+ )
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TabsRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TabsRenderer.kt
new file mode 100644
index 00000000..6b9c96b8
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TabsRenderer.kt
@@ -0,0 +1,44 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.A2UIComponent
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.TabsProperties
+import kotlinx.serialization.json.JsonPrimitive
+
+@Composable
+fun TabsRenderer(
+ props: TabsProperties,
+ context: A2UIContext
+) {
+ val selectedIndex = props.selectedIndex?.let { context.resolveNumber(it).toInt() } ?: 0
+ val tabs = props.tabs ?: emptyList()
+
+ Column {
+ TabRow(selectedTabIndex = selectedIndex) {
+ tabs.forEachIndexed { index, tabItem ->
+ val title = context.resolveString(tabItem.title) ?: ""
+ Tab(
+ selected = index == selectedIndex,
+ onClick = {
+ props.onTabSelected?.let { action ->
+ context.onUserAction(action, "unknown_source_id", mapOf("index" to JsonPrimitive(index.toDouble())))
+ }
+ },
+ text = { Text(title) }
+ )
+ }
+ }
+
+ // Render content of selected tab
+ if (selectedIndex in tabs.indices) {
+ tabs[selectedIndex].child?.let { childId ->
+ A2UIComponent(childId, context)
+ }
+ }
+ }
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TextFieldRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TextFieldRenderer.kt
new file mode 100644
index 00000000..e990b30b
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TextFieldRenderer.kt
@@ -0,0 +1,28 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.TextFieldProperties
+import kotlinx.serialization.json.JsonPrimitive
+
+@Composable
+fun TextFieldRenderer(
+ properties: TextFieldProperties,
+ context: A2UIContext
+) {
+ val value = context.resolveString(properties.value)
+ val label = context.resolveString(properties.label)
+
+ OutlinedTextField(
+ value = value,
+ onValueChange = { newValue ->
+ properties.onValueChange?.let { action ->
+ // Ideally pass newValue in action context
+ context.onUserAction(action, "textfield_needs_id", mapOf("value" to JsonPrimitive(newValue)))
+ }
+ },
+ label = { Text(label) }
+ )
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TextRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TextRenderer.kt
new file mode 100644
index 00000000..0ba8f85f
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/TextRenderer.kt
@@ -0,0 +1,28 @@
+package com.google.a2ui.compose.components
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.TextProperties
+
+@Composable
+fun TextRenderer(
+ properties: TextProperties,
+ context: A2UIContext
+) {
+ val text = context.resolveString(properties.text)
+ val style = when (properties.usageHint) {
+ "h1", "displayLarge" -> MaterialTheme.typography.displayLarge
+ "h2", "headlineLarge" -> MaterialTheme.typography.headlineLarge
+ "h3", "titleLarge" -> MaterialTheme.typography.titleLarge
+ "body", "bodyMedium" -> MaterialTheme.typography.bodyMedium
+ "caption", "labelSmall" -> MaterialTheme.typography.labelSmall
+ else -> MaterialTheme.typography.bodyMedium
+ }
+
+ Text(
+ text = text,
+ style = style
+ )
+}
diff --git a/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/VideoRenderer.kt b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/VideoRenderer.kt
new file mode 100644
index 00000000..97c87103
--- /dev/null
+++ b/renderers/android/a2ui-compose/src/main/kotlin/com/google/a2ui/compose/components/VideoRenderer.kt
@@ -0,0 +1,32 @@
+package com.google.a2ui.compose.components
+
+import android.widget.VideoView
+import android.widget.MediaController
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.viewinterop.AndroidView
+import com.google.a2ui.compose.A2UIContext
+import com.google.a2ui.core.model.VideoProperties
+
+@Composable
+fun VideoRenderer(
+ props: VideoProperties,
+ context: A2UIContext
+) {
+ val url = props.url?.let { context.resolveString(it) }
+
+ if (url != null) {
+ AndroidView(factory = { ctx ->
+ VideoView(ctx).apply {
+ setVideoURI(Uri.parse(url))
+ val mediaController = MediaController(ctx)
+ mediaController.setAnchorView(this)
+ setMediaController(mediaController)
+
+ if (props.autoPlay == true) {
+ start()
+ }
+ }
+ })
+ }
+}
diff --git a/renderers/android/a2ui-core/build.gradle.kts b/renderers/android/a2ui-core/build.gradle.kts
new file mode 100644
index 00000000..f8756907
--- /dev/null
+++ b/renderers/android/a2ui-core/build.gradle.kts
@@ -0,0 +1,22 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+ alias(libs.plugins.jetbrains.kotlin.jvm)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+tasks.withType().configureEach {
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+dependencies {
+ implementation(libs.kotlinx.serialization.json)
+ testImplementation(libs.junit)
+}
diff --git a/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Actions.kt b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Actions.kt
new file mode 100644
index 00000000..53b3e83b
--- /dev/null
+++ b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Actions.kt
@@ -0,0 +1,25 @@
+package com.google.a2ui.core.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+@Serializable
+sealed class ClientMessage {
+ @Serializable
+ @SerialName("userAction")
+ data class UserAction(
+ val name: String,
+ val surfaceId: String,
+ val sourceComponentId: String,
+ val timestamp: String,
+ val context: Map
+ ) : ClientMessage()
+
+ @Serializable
+ @SerialName("error")
+ data class Error(
+ val message: String,
+ val details: String? = null
+ ) : ClientMessage()
+}
diff --git a/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Component.kt b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Component.kt
new file mode 100644
index 00000000..64375368
--- /dev/null
+++ b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Component.kt
@@ -0,0 +1,155 @@
+package com.google.a2ui.core.model
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+@Serializable
+data class BoundValue(
+ val literalString: String? = null,
+ val literalNumber: Double? = null,
+ val literalBoolean: Boolean? = null,
+ val path: String? = null
+)
+
+@Serializable
+data class TextProperties(
+ val text: BoundValue? = null,
+ val usageHint: String? = null
+)
+
+@Serializable
+data class ButtonProperties(
+ val label: BoundValue? = null,
+ val child: String? = null,
+ val action: Action? = null
+)
+
+@Serializable
+data class ContainerProperties(
+ val children: Children? = null,
+ val alignment: String? = null // start, end, center, space-between
+)
+
+@Serializable
+data class Children(
+ val explicitList: List? = null,
+ val template: Template? = null
+)
+
+@Serializable
+data class Template(
+ val dataBinding: String,
+ val componentId: String
+)
+
+@Serializable
+data class ImageProperties(
+ val url: BoundValue? = null,
+ val altText: BoundValue? = null
+)
+
+@Serializable
+data class TextFieldProperties(
+ val label: BoundValue? = null,
+ val value: BoundValue? = null,
+ val onValueChange: Action? = null
+)
+
+@Serializable
+data class Action(
+ val name: String,
+ val context: List? = null
+)
+
+@Serializable
+data class ContextEntry(
+ val key: String,
+ val value: BoundValue
+)
+
+@Serializable
+data class CheckboxProperties(
+ val checked: BoundValue? = null,
+ val label: BoundValue? = null,
+ val onCheckedChange: Action? = null
+)
+
+@Serializable
+data class SliderProperties(
+ val value: BoundValue? = null,
+ val min: Double? = null,
+ val max: Double? = null,
+ val onValueChange: Action? = null
+)
+
+@Serializable
+data class SwitchProperties(
+ val checked: BoundValue? = null,
+ val label: BoundValue? = null,
+ val onCheckedChange: Action? = null
+)
+
+@Serializable
+data class CardProperties(
+ val child: String? = null,
+ // A2UI spec usually wraps a single child or children. Lit implementation uses 'child'.
+ // We can support padding/elevation here if extending core spec, but adhering to base for now.
+)
+
+@Serializable
+data class TabsProperties(
+ val tabs: List? = null,
+ val selectedIndex: BoundValue? = null,
+ val onTabSelected: Action? = null
+)
+
+@Serializable
+data class TabItem(
+ val title: BoundValue,
+ val child: String? = null // Reference to content component ID
+)
+
+@Serializable
+data class ModalProperties(
+ val title: BoundValue? = null,
+ val content: String? = null, // ID of content component
+ val isOpen: BoundValue? = null,
+ val onDismiss: Action? = null,
+ val actions: List? = null // IDs of action buttons
+)
+
+@Serializable
+data class DateTimeInputProperties(
+ val label: BoundValue? = null,
+ val value: BoundValue? = null, // ISO8601 string
+ val onValueChange: Action? = null
+)
+
+// Simplified Media properties
+@Serializable
+data class VideoProperties(
+ val url: BoundValue? = null,
+ val autoPlay: Boolean? = false,
+ val controls: Boolean? = true
+)
+
+@Serializable
+data class AudioProperties(
+ val url: BoundValue? = null,
+ val autoPlay: Boolean? = false,
+ val controls: Boolean? = true
+)
+
+@Serializable
+data class IconProperties(
+ val name: BoundValue? = null,
+ val size: Double? = null,
+ val color: String? = null
+)
+
+@Serializable
+data class DividerProperties(
+ val thickness: Double? = null,
+ val color: String? = null
+)
+
diff --git a/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Messages.kt b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Messages.kt
new file mode 100644
index 00000000..abb800cf
--- /dev/null
+++ b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/model/Messages.kt
@@ -0,0 +1,75 @@
+package com.google.a2ui.core.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+
+@Serializable
+sealed class ServerMessage {
+ @Serializable
+ @SerialName("surfaceUpdate")
+ data class SurfaceUpdate(
+ val surfaceId: String,
+ val components: List
+ ) : ServerMessage()
+
+ @Serializable
+ @SerialName("dataModelUpdate")
+ data class DataModelUpdate(
+ val surfaceId: String,
+ val path: String? = null,
+ val contents: List
+ ) : ServerMessage()
+
+ @Serializable
+ @SerialName("beginRendering")
+ data class BeginRendering(
+ val surfaceId: String,
+ val root: String,
+ val catalogId: String? = null,
+ val styles: JsonObject? = null
+ ) : ServerMessage()
+
+ @Serializable
+ @SerialName("deleteSurface")
+ data class DeleteSurface(
+ val surfaceId: String
+ ) : ServerMessage()
+}
+
+@Serializable
+data class ComponentInstance(
+ val id: String,
+ val component: ComponentWrapper
+)
+
+@Serializable
+data class ComponentWrapper(
+ val Text: TextProperties? = null,
+ val Button: ButtonProperties? = null,
+ val Column: ContainerProperties? = null,
+ val Row: ContainerProperties? = null,
+ val Box: ContainerProperties? = null,
+ val Image: ImageProperties? = null,
+ val TextField: TextFieldProperties? = null,
+ val Checkbox: CheckboxProperties? = null,
+ val Slider: SliderProperties? = null,
+ val Card: CardProperties? = null,
+ val Tabs: TabsProperties? = null,
+ val Modal: ModalProperties? = null,
+ val DateTimeInput: DateTimeInputProperties? = null,
+ val Video: VideoProperties? = null,
+ val Audio: AudioProperties? = null,
+ val Icon: IconProperties? = null,
+ val Divider: DividerProperties? = null
+)
+
+@Serializable
+data class DataEntry(
+ val key: String,
+ val valueString: String? = null,
+ val valueNumber: Double? = null, // Using Double for generic number
+ val valueBoolean: Boolean? = null,
+ val valueMap: List? = null
+)
diff --git a/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/state/SurfaceState.kt b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/state/SurfaceState.kt
new file mode 100644
index 00000000..fa11b7e4
--- /dev/null
+++ b/renderers/android/a2ui-core/src/main/kotlin/com/google/a2ui/core/state/SurfaceState.kt
@@ -0,0 +1,105 @@
+package com.google.a2ui.core.state
+
+import com.google.a2ui.core.model.BoundValue
+import com.google.a2ui.core.model.ComponentInstance
+import com.google.a2ui.core.model.ComponentWrapper
+import com.google.a2ui.core.model.DataEntry
+import com.google.a2ui.core.model.ServerMessage
+import kotlinx.serialization.json.JsonElement
+
+class SurfaceState {
+ private val _components = mutableMapOf()
+ val components: Map get() = _components
+
+ private val _dataModel = mutableMapOf()
+ val dataModel: Map get() = _dataModel
+
+ var rootId: String? = null
+ private set
+
+ fun applyUpdate(message: ServerMessage) {
+ when (message) {
+ is ServerMessage.SurfaceUpdate -> {
+ message.components.forEach { instance ->
+ _components[instance.id] = instance.component
+ }
+ }
+ is ServerMessage.DataModelUpdate -> {
+ applyDataUpdate(message.path, message.contents)
+ }
+ is ServerMessage.BeginRendering -> {
+ rootId = message.root
+ // Styles would be handled here
+ }
+ is ServerMessage.DeleteSurface -> {
+ _components.clear()
+ _dataModel.clear()
+ rootId = null
+ }
+ }
+ }
+
+ private fun applyDataUpdate(path: String?, contents: List) {
+ val targetMap = if (path.isNullOrEmpty() || path == "/") {
+ _dataModel
+ } else {
+ // Traverse to path - simplifed for now, robust implementation needs path parsing
+ // For MVP assuming flat or simple paths for update root
+ // Making this a recursive update or pointer retrieval is needed for deep updates
+ // For now, let's just support root updates or shallow updates for MVP simplicity
+ // TODO: Implement deep path traversal
+ _dataModel
+ }
+
+ contents.forEach { entry ->
+ val value = resolveDataEntry(entry)
+ targetMap[entry.key] = value
+ }
+ }
+
+ private fun resolveDataEntry(entry: DataEntry): Any? {
+ return when {
+ entry.valueString != null -> entry.valueString
+ entry.valueNumber != null -> entry.valueNumber
+ entry.valueBoolean != null -> entry.valueBoolean
+ entry.valueMap != null -> {
+ val map = mutableMapOf()
+ entry.valueMap.forEach { childEntry ->
+ map[childEntry.key] = resolveDataEntry(childEntry)
+ }
+ map
+ }
+ else -> null
+ }
+ }
+
+ fun resolve(boundValue: BoundValue?): Any? {
+ if (boundValue == null) return null
+
+ // Priority: Literal > Path
+ if (boundValue.literalString != null) return boundValue.literalString
+ if (boundValue.literalNumber != null) return boundValue.literalNumber
+ if (boundValue.literalBoolean != null) return boundValue.literalBoolean
+
+ if (boundValue.path != null) {
+ return resolvePath(boundValue.path)
+ }
+
+ return null
+ }
+
+ private fun resolvePath(path: String): Any? {
+ // Basic path resolution: /user/name -> dataModel["user"]["name"]
+ val parts = path.split('/').filter { it.isNotEmpty() }
+ var current: Any? = _dataModel
+
+ for (part in parts) {
+ if (current is Map<*, *>) {
+ current = current[part]
+ } else {
+ return null
+ }
+ }
+ return current
+ }
+}
diff --git a/renderers/android/build.gradle.kts b/renderers/android/build.gradle.kts
new file mode 100644
index 00000000..8309f9bf
--- /dev/null
+++ b/renderers/android/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file for the Android Renderer Library
+plugins {
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.jetbrains.kotlin.jvm) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+}
diff --git a/renderers/android/gradle.properties b/renderers/android/gradle.properties
new file mode 100644
index 00000000..e892c3bc
--- /dev/null
+++ b/renderers/android/gradle.properties
@@ -0,0 +1,2 @@
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx4g
diff --git a/renderers/android/gradle/libs.versions.toml b/renderers/android/gradle/libs.versions.toml
new file mode 100644
index 00000000..9bf74db3
--- /dev/null
+++ b/renderers/android/gradle/libs.versions.toml
@@ -0,0 +1,36 @@
+[versions]
+agp = "8.4.0"
+kotlin = "1.9.23"
+coreKtx = "1.13.1"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+lifecycleRuntimeKtx = "2.8.0"
+activityCompose = "1.9.0"
+composeBom = "2024.05.00"
+kotlinxSerialization = "1.6.3"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
diff --git a/renderers/android/settings.gradle.kts b/renderers/android/settings.gradle.kts
new file mode 100644
index 00000000..5812000b
--- /dev/null
+++ b/renderers/android/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+}
+
+rootProject.name = "A2UI-Android-Renderer"
+include(":a2ui-core")
+include(":a2ui-compose")
diff --git a/renderers/lit/package-lock.json b/renderers/lit/package-lock.json
index 26b270e0..c7c9c4f9 100644
--- a/renderers/lit/package-lock.json
+++ b/renderers/lit/package-lock.json
@@ -993,7 +993,8 @@
"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"
+ "license": "Apache-2.0",
+ "peer": true
},
"node_modules/signal-utils": {
"version": "0.21.1",
diff --git a/renderers/lit/package.json b/renderers/lit/package.json
index 6cc360cb..1a979e11 100644
--- a/renderers/lit/package.json
+++ b/renderers/lit/package.json
@@ -30,9 +30,10 @@
},
"wireit": {
"copy-spec": {
- "command": "mkdir -p src/0.8/schemas && cp ../../specification/0.8/json/*.json src/0.8/schemas",
+ "command": "node scripts/copy-spec.js",
"files": [
- "../../specification/0.8/json/*.json"
+ "../../specification/0.8/json/*.json",
+ "scripts/copy-spec.js"
],
"output": [
"src/0.8/schemas/*.json"
@@ -105,4 +106,4 @@
"markdown-it": "^14.1.0",
"signal-utils": "^0.21.1"
}
-}
+}
\ No newline at end of file
diff --git a/renderers/lit/scripts/copy-spec.js b/renderers/lit/scripts/copy-spec.js
new file mode 100644
index 00000000..88acaf64
--- /dev/null
+++ b/renderers/lit/scripts/copy-spec.js
@@ -0,0 +1,29 @@
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const srcDir = path.resolve(__dirname, '../../../specification/0.8/json');
+const destDir = path.resolve(__dirname, '../src/0.8/schemas');
+
+console.log(`Copying specs from ${srcDir} to ${destDir}`);
+
+if (!fs.existsSync(destDir)) {
+ fs.mkdirSync(destDir, { recursive: true });
+}
+
+if (!fs.existsSync(srcDir)) {
+ console.error(`Source directory not found: ${srcDir}`);
+ process.exit(1);
+}
+
+const files = fs.readdirSync(srcDir);
+
+files.forEach(file => {
+ if (path.extname(file) === '.json') {
+ fs.copyFileSync(path.join(srcDir, file), path.join(destDir, file));
+ console.log(`Copied ${file}`);
+ }
+});
diff --git a/samples/agent/adk/contact_lookup/README.md b/samples/agent/adk/contact_lookup/README.md
index 976d6200..ce6e2170 100644
--- a/samples/agent/adk/contact_lookup/README.md
+++ b/samples/agent/adk/contact_lookup/README.md
@@ -28,6 +28,15 @@ This sample uses the Agent Development Kit (ADK) along with the A2A protocol to
uv run .
```
+## Using with Android Client
+
+This agent supports the Android A2UI Client sample.
+
+1. Ensure this agent is running (serving at `http://localhost:10003`).
+2. Open the Android project in `samples/client/android`.
+3. Run the **Contact** sample app on an emulator.
+4. The app will automatically connect to this agent and display the UI.
+
## Disclaimer
diff --git a/samples/agent/adk/contact_lookup/source_code.txt b/samples/agent/adk/contact_lookup/source_code.txt
new file mode 100644
index 00000000..5f8a312c
Binary files /dev/null and b/samples/agent/adk/contact_lookup/source_code.txt differ
diff --git a/samples/agent/adk/contact_lookup/verify_routes_httpx.py b/samples/agent/adk/contact_lookup/verify_routes_httpx.py
new file mode 100644
index 00000000..9310f150
--- /dev/null
+++ b/samples/agent/adk/contact_lookup/verify_routes_httpx.py
@@ -0,0 +1,76 @@
+import httpx
+import json
+import uuid
+
+URL = "http://localhost:10003/"
+
+def test_method(method_name):
+ print(f"\n--- Testing method: '{method_name}' ---")
+
+ # Construct a valid A2A message payload
+ message_id = str(uuid.uuid4())
+ params = {
+ "message": {
+ "messageId": message_id,
+ "role": "user",
+ "parts": [{"text": "Hello"}],
+ "kind": "message"
+ }
+ }
+
+ payload = {
+ "jsonrpc": "2.0",
+ "method": method_name,
+ "params": params,
+ "id": "1"
+ }
+
+ headers = {
+ "Content-Type": "application/json",
+ "X-A2A-Extensions": "https://a2ui.org/a2a-extension/a2ui/v0.8"
+ }
+
+ try:
+ response = httpx.post(URL, json=payload, headers=headers)
+ # print(f"Status: {response.status_code}")
+ # print(f"Response: {response.text}")
+
+ if "Method not found" not in response.text:
+ print(f"✅ SUCCESS? '{method_name}' looked promising!")
+ return True
+ else:
+ print(f"❌ Failed: Method not found")
+ return False
+
+ except Exception as e:
+ print(f"Error: {e}")
+ return False
+
+if __name__ == "__main__":
+ candidates = [
+ # Based on DefaultRequestHandler.on_message_send
+ "on_message_send",
+ "message_send",
+ "message.send",
+ "a2a.message_send",
+ "a2a.on_message_send",
+
+ # Variations
+ "post_message",
+ "a2a.post_message",
+ "POST", # Failed
+
+ # Maybe namespaced?
+ "a2a/message_send",
+ "v1/message_send",
+ ]
+
+ found = False
+ for c in candidates:
+ if test_method(c):
+ found = True
+ break
+
+ if not found:
+ print("\nAll candidates failed.")
+ # Try listing? NO standard listing in JSON-RPC usually.
diff --git a/samples/client/android/.idea/.gitignore b/samples/client/android/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/samples/client/android/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/samples/client/android/.idea/.name b/samples/client/android/.idea/.name
new file mode 100644
index 00000000..f1e58f5a
--- /dev/null
+++ b/samples/client/android/.idea/.name
@@ -0,0 +1 @@
+A2UI-Android-Sample
\ No newline at end of file
diff --git a/samples/client/android/.idea/compiler.xml b/samples/client/android/.idea/compiler.xml
new file mode 100644
index 00000000..b589d56e
--- /dev/null
+++ b/samples/client/android/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/deploymentTargetSelector.xml b/samples/client/android/.idea/deploymentTargetSelector.xml
new file mode 100644
index 00000000..5272c0ed
--- /dev/null
+++ b/samples/client/android/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/gradle.xml b/samples/client/android/.idea/gradle.xml
new file mode 100644
index 00000000..0be14505
--- /dev/null
+++ b/samples/client/android/.idea/gradle.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/inspectionProfiles/Project_Default.xml b/samples/client/android/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..103e00cb
--- /dev/null
+++ b/samples/client/android/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/kotlinc.xml b/samples/client/android/.idea/kotlinc.xml
new file mode 100644
index 00000000..fe63bb67
--- /dev/null
+++ b/samples/client/android/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/migrations.xml b/samples/client/android/.idea/migrations.xml
new file mode 100644
index 00000000..f8051a6f
--- /dev/null
+++ b/samples/client/android/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/misc.xml b/samples/client/android/.idea/misc.xml
new file mode 100644
index 00000000..0ad17cbd
--- /dev/null
+++ b/samples/client/android/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/other.xml b/samples/client/android/.idea/other.xml
new file mode 100644
index 00000000..c9a97cc8
--- /dev/null
+++ b/samples/client/android/.idea/other.xml
@@ -0,0 +1,1077 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/.idea/vcs.xml b/samples/client/android/.idea/vcs.xml
new file mode 100644
index 00000000..c2365ab1
--- /dev/null
+++ b/samples/client/android/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/README.md b/samples/client/android/README.md
new file mode 100644
index 00000000..1fd6391d
--- /dev/null
+++ b/samples/client/android/README.md
@@ -0,0 +1,60 @@
+# A2A Android Client Samples
+
+This directory contains sample Android applications demonstrating the A2UI native renderer receiving streaming UI updates from an AI agent.
+
+## Project Structure
+
+This sample uses a **Composite Build** to include the renderer source code directly from `../../../../renderers/android`.
+
+- `projects/contact/`: **Contact Lookup Sample**. A client that connects to the `contact_lookup` agent to display a dynamic contact card.
+- `projects/orchestrator/`: (Placeholder) Orchestrator module.
+- `projects/restaurant/`: (Placeholder) Restaurant reservation module.
+
+## Prerequisites
+
+- **Android Studio**: Koala Feature Drop or newer (recommended).
+- **JDK**: Java 17+.
+- **Python**: 3.10+ (for running the agent).
+
+## Setup & Running
+
+### 1. Start the AI Agent
+The Android client needs a backend agent to talk to.
+
+1. Open a terminal.
+2. Navigate to the contact lookup agent directory:
+ ```bash
+ cd samples/agent/adk/contact_lookup
+ ```
+3. Install dependencies and run:
+ ```bash
+ uv run .
+ ```
+ The agent will start at `http://localhost:10003`.
+
+### 2. Run the Android App
+
+1. **Open in Android Studio**:
+ - Select **File > Open**.
+ - Navigate to `samples/client/android` and select `settings.gradle.kts`.
+ - Wait for Gradle Sync to complete.
+
+2. **Run**:
+ - Select the **`projects.contact`** (or `contact`) configuration in the run toolbar.
+ - Select an **Android Emulator** (Physical devices require reverse port forwarding).
+ - Click **Run** (Green Play button).
+
+### 3. Usage
+- The app will launch and send a default query: "Find contact info for Alex Jordan".
+- The agent will respond with a stream of UI components.
+- The app renders the profile image, text, and icons dynamically.
+
+## Troubleshooting
+
+- **Blank Screen?** Check Logcat for `A2AClient` logs. Ensure the agent is running.
+- **Connection Refused?** Ensure you are on an Emulator. If using a physical device, run:
+ ```bash
+ adb reverse tcp:10003 tcp:10003
+ ```
+- **Build Errors?** Ensure you have JDK 17 selected in Android Studio (Settings > Build, Execution, Deployment > Build Tools > Gradle).
+
diff --git a/samples/client/android/build.gradle.kts b/samples/client/android/build.gradle.kts
new file mode 100644
index 00000000..816d6e9d
--- /dev/null
+++ b/samples/client/android/build.gradle.kts
@@ -0,0 +1,8 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.jetbrains.kotlin.jvm) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+}
diff --git a/samples/client/android/gradle.properties b/samples/client/android/gradle.properties
new file mode 100644
index 00000000..e892c3bc
--- /dev/null
+++ b/samples/client/android/gradle.properties
@@ -0,0 +1,2 @@
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx4g
diff --git a/samples/client/android/gradle/wrapper/gradle-wrapper.jar b/samples/client/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..d64cd491
Binary files /dev/null and b/samples/client/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/samples/client/android/gradle/wrapper/gradle-wrapper.properties b/samples/client/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..a80b22ce
--- /dev/null
+++ b/samples/client/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/samples/client/android/gradlew b/samples/client/android/gradlew
new file mode 100644
index 00000000..1aa94a42
--- /dev/null
+++ b/samples/client/android/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 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.
+#
+
+##############################################################################
+#
+# 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/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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, 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" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# 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/samples/client/android/gradlew.bat b/samples/client/android/gradlew.bat
new file mode 100644
index 00000000..93e3f59f
--- /dev/null
+++ b/samples/client/android/gradlew.bat
@@ -0,0 +1,92 @@
+@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
+
+@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.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+: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/samples/client/android/projects/contact/.idea/.gitignore b/samples/client/android/projects/contact/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/samples/client/android/projects/contact/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/samples/client/android/projects/contact/.idea/gradle.xml b/samples/client/android/projects/contact/.idea/gradle.xml
new file mode 100644
index 00000000..89935b50
--- /dev/null
+++ b/samples/client/android/projects/contact/.idea/gradle.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/projects/contact/.idea/migrations.xml b/samples/client/android/projects/contact/.idea/migrations.xml
new file mode 100644
index 00000000..f8051a6f
--- /dev/null
+++ b/samples/client/android/projects/contact/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/projects/contact/.idea/misc.xml b/samples/client/android/projects/contact/.idea/misc.xml
new file mode 100644
index 00000000..3040d03e
--- /dev/null
+++ b/samples/client/android/projects/contact/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/projects/contact/.idea/other.xml b/samples/client/android/projects/contact/.idea/other.xml
new file mode 100644
index 00000000..c9a97cc8
--- /dev/null
+++ b/samples/client/android/projects/contact/.idea/other.xml
@@ -0,0 +1,1077 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/projects/contact/.idea/vcs.xml b/samples/client/android/projects/contact/.idea/vcs.xml
new file mode 100644
index 00000000..bc599707
--- /dev/null
+++ b/samples/client/android/projects/contact/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/client/android/projects/contact/build.gradle.kts b/samples/client/android/projects/contact/build.gradle.kts
new file mode 100644
index 00000000..f7afee11
--- /dev/null
+++ b/samples/client/android/projects/contact/build.gradle.kts
@@ -0,0 +1,73 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ kotlin("plugin.serialization")
+}
+
+android {
+ namespace = "com.google.a2ui.sample"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.google.a2ui.sample"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.11"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation("com.google.a2ui.compose:a2ui-compose")
+ implementation("com.google.a2ui.core:a2ui-core")
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+ debugImplementation(libs.androidx.ui.test.manifest)
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
+}
diff --git a/samples/client/android/projects/contact/src/main/AndroidManifest.xml b/samples/client/android/projects/contact/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..7cc03eab
--- /dev/null
+++ b/samples/client/android/projects/contact/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/client/android/projects/contact/src/main/java/com/google/a2ui/sample/A2AClient.kt b/samples/client/android/projects/contact/src/main/java/com/google/a2ui/sample/A2AClient.kt
new file mode 100644
index 00000000..91c24ab2
--- /dev/null
+++ b/samples/client/android/projects/contact/src/main/java/com/google/a2ui/sample/A2AClient.kt
@@ -0,0 +1,214 @@
+package com.google.a2ui.sample
+
+import com.google.a2ui.core.model.ServerMessage
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.IOException
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+// --- A2A Protocol Data Models ---
+
+@Serializable
+data class A2AClientMessage(
+ val message: ClientMessageEnvelope
+)
+
+@Serializable
+data class ClientMessageEnvelope(
+ val messageId: String,
+ val role: String = "user",
+ val parts: List,
+ val kind: String = "message"
+)
+
+@Serializable
+sealed class ClientPart {
+ @Serializable
+ @SerialName("text")
+ data class Text(val text: String) : ClientPart()
+
+ @Serializable
+ @SerialName("data")
+ data class Data(
+ val data: JsonElement,
+ val metadata: Map = mapOf("mimeType" to "application/json+a2aui")
+ ) : ClientPart()
+}
+
+// Response models from the A2A agent (Simplified for what we need)
+// Response models from the A2A agent (Simplified for what we need)
+@Serializable
+data class A2AResult(
+ val kind: String? = null, // "task"
+ val status: A2ATaskStatus? = null,
+ val error: A2AError? = null // In case error is inside result
+)
+
+@Serializable
+data class A2AError(
+ val message: String
+)
+
+@Serializable
+data class A2ATaskStatus(
+ val message: ServerMessageEnvelope? = null
+)
+
+@Serializable
+data class ServerMessageEnvelope(
+ val parts: List? = null
+)
+
+@Serializable
+data class ServerPart(
+ val data: JsonElement? = null,
+ val text: String? = null
+)
+
+
+// --- JSON-RPC Wrapper Models ---
+@Serializable
+data class JsonRpcRequest(
+ val jsonrpc: String = "2.0",
+ val method: String,
+ val params: A2AClientMessage,
+ val id: String
+)
+
+@Serializable
+data class JsonRpcResponse(
+ val jsonrpc: String,
+ val result: A2AResult? = null,
+ val error: JsonRpcError? = null,
+ val id: String
+)
+
+@Serializable
+data class JsonRpcError(
+ val code: Int,
+ val message: String,
+ val data: JsonElement? = null
+)
+
+
+
+class A2AClient(
+ private val agentUrl: String = "http://10.0.2.2:10003/" // Default to local agent root
+) {
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(90, TimeUnit.SECONDS)
+ .readTimeout(90, TimeUnit.SECONDS)
+ .writeTimeout(90, TimeUnit.SECONDS)
+ .build()
+ private val json = Json {
+ ignoreUnknownKeys = true
+ classDiscriminator = "kind" // For sealed classes
+ encodeDefaults = true
+ }
+
+ private val mediaType = "application/json; charset=utf-8".toMediaType()
+
+ @Throws(IOException::class)
+ fun sendMessage(text: String): List {
+ val part = ClientPart.Text(text)
+ return sendInternal(part)
+ }
+
+ @Throws(IOException::class)
+ fun sendEvent(eventData: JsonElement): List {
+ val part = ClientPart.Data(eventData)
+ return sendInternal(part)
+ }
+
+ private fun sendInternal(part: ClientPart): List {
+ val messageId = UUID.randomUUID().toString()
+ val envelope = A2AClientMessage(
+ message = ClientMessageEnvelope(
+ messageId = messageId,
+ parts = listOf(part)
+ )
+ )
+
+ // Wrap in JSON-RPC
+ val rpcRequest = JsonRpcRequest(
+ method = "message/send",
+ params = envelope,
+ id = messageId
+ )
+
+ val requestBody = json.encodeToString(rpcRequest).toRequestBody(mediaType)
+
+ // Log the request body for debugging
+ // android.util.Log.d("A2AClient", "Request: ${json.encodeToString(rpcRequest)}")
+
+ val request = Request.Builder()
+ .url(agentUrl)
+ .addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.8") // Crucial!
+ .post(requestBody)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) throw IOException("Unexpected code $response")
+
+ val responseBody = response.body?.string() ?: throw IOException("Empty response body")
+ android.util.Log.d("A2AClient", "Raw Response: $responseBody")
+
+ // Decode JSON-RPC response
+ val rpcResponse = try {
+ json.decodeFromString(responseBody)
+ } catch (e: Exception) {
+ throw IOException("Failed to parse JSON-RPC response: ${e.message}. Body: $responseBody")
+ }
+
+ if (rpcResponse.error != null) {
+ throw IOException("JSON-RPC Error ${rpcResponse.error.code}: ${rpcResponse.error.message}")
+ }
+
+ val a2aResult = rpcResponse.result ?: throw IOException("Empty results in JSON-RPC response")
+
+ if (a2aResult.error != null) {
+ throw IOException("Agent Error: ${a2aResult.error.message}")
+ }
+
+ // If the status is "done", there might be no message. Access safely.
+ val parts = a2aResult.status?.message?.parts ?: emptyList()
+
+ // Extract only the A2UI updates
+ val uiParts = parts.mapNotNull { part ->
+ if (part.data != null) {
+ val jsonElement = part.data
+ if (jsonElement is JsonObject) {
+ try {
+ when {
+ "beginRendering" in jsonElement -> json.decodeFromJsonElement(ServerMessage.BeginRendering.serializer(), jsonElement["beginRendering"]!!)
+ "surfaceUpdate" in jsonElement -> json.decodeFromJsonElement(ServerMessage.SurfaceUpdate.serializer(), jsonElement["surfaceUpdate"]!!)
+ "dataModelUpdate" in jsonElement -> json.decodeFromJsonElement(ServerMessage.DataModelUpdate.serializer(), jsonElement["dataModelUpdate"]!!)
+ "deleteSurface" in jsonElement -> json.decodeFromJsonElement(ServerMessage.DeleteSurface.serializer(), jsonElement["deleteSurface"]!!)
+ else -> null
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("A2AClient", "Failed to decode message part: ${e.message}")
+ null
+ }
+ } else null
+ } else null
+ }
+
+ android.util.Log.d("A2AClient", "Received ${uiParts.size} UI parts out of ${parts.size} total parts.")
+ uiParts.forEachIndexed { index, part ->
+ android.util.Log.d("A2AClient", "UI Part $index: $part")
+ }
+
+ return uiParts
+ }
+ }
+}
diff --git a/samples/client/android/projects/contact/src/main/java/com/google/a2ui/sample/MainActivity.kt b/samples/client/android/projects/contact/src/main/java/com/google/a2ui/sample/MainActivity.kt
new file mode 100644
index 00000000..8a564d92
--- /dev/null
+++ b/samples/client/android/projects/contact/src/main/java/com/google/a2ui/sample/MainActivity.kt
@@ -0,0 +1,133 @@
+package com.google.a2ui.sample
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.ui.unit.dp
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.google.a2ui.compose.A2UISurface
+import com.google.a2ui.core.model.ServerMessage
+import com.google.a2ui.core.state.SurfaceState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Surface(modifier = Modifier.padding(innerPadding)) {
+ SampleA2UIScreen()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SampleA2UIScreen() {
+ val surfaceState = remember { SurfaceState() }
+ var isLoading by remember { mutableStateOf(false) }
+ var errorMsg by remember { mutableStateOf(null) }
+ var query by remember { mutableStateOf("Find contact info for Alex Jordan") }
+
+ // Initialize our native A2A Client
+ val a2aClient = remember { A2AClient() }
+ val scope = rememberCoroutineScope()
+
+ androidx.compose.foundation.layout.Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // Input Area
+ androidx.compose.foundation.layout.Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ androidx.compose.material3.TextField(
+ value = query,
+ onValueChange = { query = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Ask Agent") }
+ )
+ androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp))
+ androidx.compose.material3.Button(
+ onClick = {
+ isLoading = true
+ errorMsg = null
+ scope.launch(Dispatchers.IO) {
+ try {
+ val messages = a2aClient.sendMessage(query)
+ withContext(Dispatchers.Main) {
+ messages.forEach { surfaceState.applyUpdate(it) }
+ isLoading = false
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ withContext(Dispatchers.Main) {
+ errorMsg = "Error: ${e.message}"
+ isLoading = false
+ }
+ }
+ }
+ },
+ enabled = !isLoading
+ ) {
+ Text("Send")
+ }
+ }
+
+ // Content Area
+ Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ if (isLoading) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ } else if (errorMsg != null) {
+ Text(
+ text = errorMsg!!,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.align(Alignment.Center).padding(16.dp)
+ )
+ } else {
+ A2UISurface(
+ surfaceId = "contact-card",
+ state = surfaceState,
+ onUserAction = { action, src, contextMap ->
+ Log.d("A2UI", "Action: ${action.name} from $src")
+
+ val actionData = kotlinx.serialization.json.JsonObject(contextMap)
+
+ scope.launch(Dispatchers.IO) {
+ try {
+ val updates = a2aClient.sendEvent(actionData)
+ withContext(Dispatchers.Main) {
+ updates.forEach { surfaceState.applyUpdate(it) }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/samples/client/android/projects/orchestrator/build.gradle.kts b/samples/client/android/projects/orchestrator/build.gradle.kts
new file mode 100644
index 00000000..034bdef2
--- /dev/null
+++ b/samples/client/android/projects/orchestrator/build.gradle.kts
@@ -0,0 +1,40 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+}
+
+android {
+ namespace = "com.google.a2ui.sample.orchestrator"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.google.a2ui.sample.orchestrator"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.material3)
+}
diff --git a/samples/client/android/projects/orchestrator/src/main/AndroidManifest.xml b/samples/client/android/projects/orchestrator/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..0ca87b98
--- /dev/null
+++ b/samples/client/android/projects/orchestrator/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/samples/client/android/projects/restaurant/build.gradle.kts b/samples/client/android/projects/restaurant/build.gradle.kts
new file mode 100644
index 00000000..e0404968
--- /dev/null
+++ b/samples/client/android/projects/restaurant/build.gradle.kts
@@ -0,0 +1,40 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+}
+
+android {
+ namespace = "com.google.a2ui.sample.restaurant"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.google.a2ui.sample.restaurant"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.material3)
+}
diff --git a/samples/client/android/projects/restaurant/src/main/AndroidManifest.xml b/samples/client/android/projects/restaurant/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..5af87d55
--- /dev/null
+++ b/samples/client/android/projects/restaurant/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/samples/client/android/sample_data.jsonl b/samples/client/android/sample_data.jsonl
new file mode 100644
index 00000000..e7c4e1ea
--- /dev/null
+++ b/samples/client/android/sample_data.jsonl
@@ -0,0 +1,5 @@
+{"surfaceUpdate": {"surfaceId": "demo", "components": [{"id": "root", "component": {"Column": {"children": {"explicitList": ["card_1", "card_2"]}}}}]} }
+{"surfaceUpdate": {"surfaceId": "demo", "components": [{"id": "card_1", "component": {"Box": {"children": {"explicitList": ["text_1", "btn_1"]}}}}]} }
+{"surfaceUpdate": {"surfaceId": "demo", "components": [{"id": "text_1", "component": {"Text": {"text": {"literalString": "Hello A2UI"}, "usageHint": "h1"}}}} }
+{"surfaceUpdate": {"surfaceId": "demo", "components": [{"id": "btn_1", "component": {"Button": {"label": {"literalString": "Click Me"}, "action": {"name": "hello"}}}}]} }
+{"beginRendering": {"surfaceId": "demo", "root": "root"}}
diff --git a/samples/client/android/settings.gradle.kts b/samples/client/android/settings.gradle.kts
new file mode 100644
index 00000000..1c9f203b
--- /dev/null
+++ b/samples/client/android/settings.gradle.kts
@@ -0,0 +1,37 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+ versionCatalogs {
+ create("libs") {
+ from(files("../../../renderers/android/gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "A2UI-Android-Sample"
+include(":projects:contact")
+include(":projects:orchestrator")
+include(":projects:restaurant")
+
+includeBuild("../../../renderers/android") {
+ dependencySubstitution {
+ substitute(module("com.google.a2ui.compose:a2ui-compose")).using(project(":a2ui-compose"))
+ substitute(module("com.google.a2ui.core:a2ui-core")).using(project(":a2ui-core"))
+ }
+}
diff --git a/samples/client/lit/package-lock.json b/samples/client/lit/package-lock.json
index 677cc607..24979185 100644
--- a/samples/client/lit/package-lock.json
+++ b/samples/client/lit/package-lock.json
@@ -1060,6 +1060,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2045,6 +2046,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},