Host-rendered decisions
This page renders Grona decisions with your app’s own views. A decision tells you which products to show at a placement, such as a frequently-bought-together rail under the buy button, and you draw them with the product cells you already have. Use this when the content must look like the rest of your catalogue. For banners, progress bars, offers and messages, use a GronaSlotView instead.
Decision types
Section titled “Decision types”| Constant | Value | Typical use |
|---|---|---|
GronaDecision.RAIL |
rail |
A row of products, for example in the home feed |
GronaDecision.BUNDLE |
bundle |
Products to buy together, with an add-all button |
GronaDecision.PRODUCT_CARD |
product_card |
One featured product |
GronaDecision.SUBSTITUTE |
substitute |
A replacement for an out-of-stock cart line, on cart_line only |
Treat any other type as no decision. Newer configurations may add types that your app does not know how to draw.
Request a decision
Section titled “Request a decision”-
Add an anchor to the layout: the view the content will occupy. A
FrameLayoutwithwrap_contentheight works. Keep itVISIBLE.res/layout/activity_product.xml <FrameLayoutandroid:id="@+id/togetherAnchor"android:layout_width="match_parent"android:layout_height="wrap_content" /> -
Call
Grona.decisionon the main thread with the anchor, the placement, the screen and a listener:ProductActivity.kt import ai.grona.sdk.Gronaimport ai.grona.sdk.GronaDecisionimport ai.grona.sdk.GronaScreenimport ai.grona.sdk.GronaSubscriptionclass ProductActivity : AppCompatActivity() {private var together: GronaSubscription = GronaSubscription.NONEoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_product)val anchor = findViewById<FrameLayout>(R.id.togetherAnchor)val productId = intent.getStringExtra("productId") ?: returntogether = Grona.decision(anchor, "pdp_below_buy", GronaScreen.product(productId)) { decision ->if (decision != null && decision.type == GronaDecision.BUNDLE) {showTogether(anchor, decision)} else {anchor.removeAllViews() // stays VISIBLE, wraps to zero height}}}} -
Render the decision with your own views. Take names, prices and images from your catalogue, and wire your buttons to the decision’s actions:
private fun showTogether(anchor: FrameLayout, decision: GronaDecision) {anchor.removeAllViews()val rail = layoutInflater.inflate(R.layout.together_rail, anchor, false)rail.findViewById<TextView>(R.id.title).text = decision.title ?: getString(R.string.together_default_title)val list = rail.findViewById<LinearLayout>(R.id.products)decision.products.forEach { p ->val cell = productCell(p.productId) // your own product cellcell.setOnAddClick { decision.add(p.productId) }list.addView(cell)}rail.findViewById<Button>(R.id.addAll).apply {text = decision.ctaLabel ?: getString(R.string.add_all)setOnClickListener { decision.addAll() }}anchor.addView(rail)}
The listener runs on the main thread, never synchronously inside decision, and runs again whenever the decision changes. null means show nothing.
Keyed placements
Section titled “Keyed placements”cart_line decisions are per line. Pass the line’s product id as the key:
Grona.decision(rowView, "cart_line", GronaScreen.CART, line.productId) { decision -> bindSubstitute(rowView, decision?.takeIf { it.type == GronaDecision.SUBSTITUTE })}A substitute shows only while its line is in the basket and out of stock, so push the basket with inStock = false for that line. Call decision.replace(withProductId) from the shopper’s tap to swap the line.
The GronaDecision object
Section titled “The GronaDecision object”| Property | Type | Meaning |
|---|---|---|
placement |
String |
The placement it was requested for. |
campaignId |
String |
The campaign it came from. |
variantId |
String |
The variant within that campaign. |
type |
String |
rail, bundle, product_card or substitute. |
title, subtitle, badge, ctaLabel |
String? |
Copy from the configuration. Each may be null. |
products |
List<GronaDecisionProduct> |
The products to show, each with productId, qty and an optional label. Products your app does not know or has out of stock are already removed. |
key |
String? |
For keyed placements such as cart_line, the line this decision belongs to. |
extras |
Map<String, String> |
Layout hints from the configuration, for example position for feed rows. |
isPreview |
Boolean |
true while a preview pin produced this decision. You may badge it. |
source |
GronaSource |
Placement, campaign and variant, as passed to GronaHost. |
A decision with no products left is not delivered, so products is never empty.
Render it safely
Section titled “Render it safely”- Render
title,subtitle,badge,ctaLabeland product labels as plain text. No HTML, noLinkify, no Markdown. - Treat
extrasas untrusted hints. Check each value against what your layout accepts and ignore anything else. - Take product names, prices and images from your own catalogue, never from the decision.
Actions
Section titled “Actions”Call these from the shopper’s own tap, on the main thread. add, addAll and replace check the request, log the action, call your GronaHost and return a GronaAddResult. dismiss logs the dismissal and returns nothing.
| Method | What it does |
|---|---|
add(productId) |
Adds one unit of a product in the decision through GronaHost.add. |
add(productId, qty) |
Adds qty units. |
addAll() |
Adds every product in the decision at its qty. |
replace(withProductId) |
For substitutes: replaces the key line with withProductId through GronaHost.replace, at the line’s current quantity (capped at maxAddQuantity). |
dismiss() |
Hides this decision for the rest of the session. |
Before it calls your host, the SDK drops lines whose product is not in the decision, whose quantity is outside 1 to maxAddQuantity, that repeat a product, or that your GronaHost.product does not know or reports out of stock. It keeps at most maxAddLines lines. If nothing is left, or the decision is no longer the current one for its anchor, the call returns REJECTED without calling your host.
When an action returns REJECTED, the shopper still asked for the product. You may fall back to a plain add through your own cart code. The SDK has logged the action with its rejected result, unless the decision was no longer current. A call on a decision that is no longer current, or off the main thread, returns REJECTED without logging the action. Off the main thread the SDK also sends a warning to your GronaLogger.
Subscriptions and rebinding
Section titled “Subscriptions and rebinding”Grona.decision returns a GronaSubscription.
- The subscription drops with the anchor view. You do not have to cancel it when the screen closes.
- Binding the same anchor again replaces the previous subscription. RecyclerView rows can rebind freely in
onBindViewHolder. - The same placement, screen and key on another view continues the visit while any view for it is attached, and for 2 seconds after. So holders that rebind log
eligibleandservedonce. cancel()ends the visit. Cancel and request again when you want a fresh visit on the same screen, for example afterGrona.refreshbrings a campaign back.
override fun onBindViewHolder(holder: CartLineHolder, position: Int) { val line = lines[position] holder.bind(line) Grona.decision(holder.itemView, "cart_line", GronaScreen.CART, line.productId) { decision -> holder.showSubstitute(decision?.takeIf { it.type == GronaDecision.SUBSTITUTE }) }}If you call decision off the main thread, the SDK logs a warning and posts the call to the main thread.
Next steps
Section titled “Next steps”- Handle actions in your
GronaHost. - Read Patterns for host-rendered decisions.