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" },