Skip to content
The mobile SDKs are available on request. Email support@grona.ai to get access.

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.

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.

  1. Add an anchor to the layout: the view the content will occupy. A FrameLayout with wrap_content height works. Keep it VISIBLE.

    res/layout/activity_product.xml
    <FrameLayout
    android:id="@+id/togetherAnchor"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />
  2. Call Grona.decision on the main thread with the anchor, the placement, the screen and a listener:

    ProductActivity.kt
    import ai.grona.sdk.Grona
    import ai.grona.sdk.GronaDecision
    import ai.grona.sdk.GronaScreen
    import ai.grona.sdk.GronaSubscription
    class ProductActivity : AppCompatActivity() {
    private var together: GronaSubscription = GronaSubscription.NONE
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_product)
    val anchor = findViewById<FrameLayout>(R.id.togetherAnchor)
    val productId = intent.getStringExtra("productId") ?: return
    together = 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
    }
    }
    }
    }
  3. 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 cell
    cell.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.

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.

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 title, subtitle, badge, ctaLabel and product labels as plain text. No HTML, no Linkify, no Markdown.
  • Treat extras as 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.

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.

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 eligible and served once.
  • cancel() ends the visit. Cancel and request again when you want a fresh visit on the same screen, for example after Grona.refresh brings 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.