> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encorekit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Updating to 2.x

> Step-by-step migration guide for upgrading the Encore Android SDK from 1.x to 2.x.

## Overview

SDK 2.0 is a breaking release that makes the integration surface simpler and harder to misuse:

* **`show()` returns one result record and never throws.** Errors arrive as values on the result instead of as exceptions.
* **Purchases run through a registered [`EncorePurchaseController`](../sdk-reference/purchase-controller).** The SDK never runs purchase code you didn't write; the built-in Play Billing fallback is removed.
* **Global handler closures are retired.** Results are values at the call site; passive observation moves to the [`Encore.outcomes`](../sdk-reference/outcomes) stream.
* **The callback form is always delivered on the main thread; the suspending form resumes in your caller's context.** `show()` itself is safe to call from any thread or scope.

2.0 removes the 1.x legacy surface outright rather than deprecating it, so every call site that needs attention fails to compile. Work through the steps below until the project builds, then run the [verification checklist](#verify-the-migration). The [removed API table](#removed-apis-at-a-glance) maps every 1.x symbol to its replacement.

***

## Step 1: Update the dependency

```kotlin theme={null}
dependencies {
    implementation("com.encorekit:encore:2.0.0")
}
```

Sync and build. The compile errors that appear are your migration worklist; each one corresponds to a step below.

***

## Step 2: Register a purchase controller

This is the largest change. In 1.x, purchases ran through the `onPurchaseRequest` closure, and if no handler was registered the SDK bought the product itself through Play Billing and reported it via `onPurchaseComplete`. 2.0 removes the closure, the completion callback, and the built-in Play Billing fallback: that fallback bypassed whatever receipt validation, restore handling, and entitlement bookkeeping your app already has, and it made a missing registration look like a working purchase instead of a misconfiguration.

Implement the controller and pass it to [`configure()`](../sdk-reference/configure):

<CodeGroup>
  ```kotlin RevenueCat theme={null}
  class RevenueCatController : EncorePurchaseController {
      override suspend fun purchase(
          activity: Activity,
          request: PurchaseRequest,
      ): EncorePurchaseResult {
          return try {
              val product = Purchases.sharedInstance
                  .awaitGetProducts(listOf(request.productId))
                  .firstOrNull() ?: error("Product not in RC catalog: ${request.productId}")
              Purchases.sharedInstance.awaitPurchase(
                  PurchaseParams.Builder(activity, product).build()
              )
              EncorePurchaseResult.Purchased
          } catch (e: PurchasesTransactionException) {
              if (e.userCancelled) EncorePurchaseResult.Cancelled else throw e
          }
      }
  }

  Encore.shared.configure(
      context = this,
      apiKey = "pk_...",
      purchaseController = RevenueCatController(),
  )
  ```

  ```kotlin Your own billing stack theme={null}
  class AppPurchases(private val billing: MyBillingStack) : EncorePurchaseController {
      // Runs on Dispatchers.Main.immediate. Throw for real failures; map
      // "user cancelled" to Cancelled. Do not retain or finish the activity.
      override suspend fun purchase(
          activity: Activity,
          request: PurchaseRequest,
      ): EncorePurchaseResult {
          return when (billing.purchase(activity, request.productId, request.promoOfferId)) {
              MyOutcome.Success -> EncorePurchaseResult.Purchased
              MyOutcome.Cancelled -> EncorePurchaseResult.Cancelled
              MyOutcome.Deferred -> EncorePurchaseResult.Pending // parental approval / SCA
          }
      }
  }

  Encore.shared.configure(
      context = this,
      apiKey = "pk_...",
      purchaseController = AppPurchases(billing),
  )
  ```
</CodeGroup>

Return `Purchased`, `Cancelled`, or `Pending`; throw for real failures. The full contract is in [`EncorePurchaseController`](../sdk-reference/purchase-controller).

***

## Step 3: Update show() call sites

`show()` returns the full result record and never throws. The 1.x scope-taking overloads become an explicit callback form:

```kotlin theme={null}
// 1.x: fire-and-forget with a scope
Encore.placement("cancel_flow").show(activity, lifecycleScope)

// 2.0: callback form; delivered exactly once, on the main thread
Encore.placement("cancel_flow").show(activity) { result ->
    if (result.claim == null && result.publisher != PublisherOutcome.Purchased) proceedWithCancellation()
}
```

Result delivery is not foreground-gated: the callback fires even if your Activity backgrounded mid-flow. Don't touch views without checking your own lifecycle.

The suspending form works from any thread or scope:

```kotlin theme={null}
val result = Encore.placement("cancel_flow").show(activity)
val converted = result.claim != null || result.publisher == PublisherOutcome.Purchased
if (converted) grantAccess() else proceedWithCancellation()
```

***

## Step 4: Update result handling

1.x modeled the result as a flat triad (`Completed`, `Dismissed(reason)`, `NoOffers`), which forced a claim and a purchase to compete for a single winning case. 2.0 records the facts independently:

2.0 splits the result into `NotPresented(reason)` and `Presented(outcome)`; see [PresentationResult](../sdk-reference/presentation-result) for the full shape.

The record carries **raw facts only**: there is no SDK-computed "unlocked" verdict, so your integration decides what a claim means. Branch on the facts your flow cares about:

```kotlin theme={null}
val result = Encore.placement("paywall").show(activity)
if (result.claim != null || result.publisher == PublisherOutcome.Purchased) {
    grantAccess()   // the user claimed an offer or completed your purchase
} else {
    result.error?.let(::log)   // presentation failures are values
    runOriginalFlow()          // dismissed, declined, no offers, control cohort, ...
}
```

`result.claim` surfaces the claimed offer whenever the advertiser funnel reached `Claimed` or `Verified`; read `result.advertiser` directly to tell the two apart.

When the mechanism detail matters, read the record:

```kotlin theme={null}
when (result) {
    is PresentationResult.NotPresented -> log(result.reason)
    is PresentationResult.Presented -> {
        result.claim?.let { syncEntitlement(it.campaignId, it.transactionId) }
        if (result.outcome.publisher == PublisherOutcome.Purchased) refreshSubscriptions()
        log("closed via ${result.outcome.dismissal.value}")
    }
}
```

***

## Step 5: Replace global callbacks with the outcomes stream

`onPassthrough` and `onPurchaseComplete` are removed. Handle fallback inline at the call site (Step 4), and move passive observation (analytics, logging, cross-cutting state) to the `Encore.outcomes` stream:

```kotlin theme={null}
// Application.onCreate, right after configure. There is no replay, so
// subscribe at startup.
appScope.launch {
    Encore.outcomes.collect { outcome ->
        when (outcome) {
            is PlacementOutcome.Presentation ->
                analytics.log("encore_outcome", outcome.placementId, outcome.result)
            is PlacementOutcome.StrictUnlockVerified ->
                grantEntitlement(outcome.transactionId) // strict-mode cross-launch settlement
        }
    }
}
```

Every `show()` resolution lands on `Encore.outcomes`, including `NotPresented`, plus strict-unlock verifications that settle on a later launch.

***

## Removed APIs at a glance

| 1.x surface                                                       | 2.0 replacement                                                                                                                                 |
| :---------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- |
| `PresentationResult.Completed(offerId, campaignId)`               | `Presented(outcome)` with [`outcome.advertiser`](../sdk-reference/presentation-result#advertiseroutcome) = `Claimed(offer)` / `Verified(offer)` |
| `PresentationResult.Dismissed(reason)`                            | `Presented(outcome)`; `outcome.dismissal` is the [`DismissReason`](../sdk-reference/dismiss-reason)                                             |
| `PresentationResult.NoOffers`                                     | [`NotPresented(NoOffers)`](../sdk-reference/presentation-result#notpresentedreason)                                                             |
| `DismissReason.USER_CLOSED` etc.                                  | `DismissReason.UserTappedClose` etc.: one enum, raw values are the wire values                                                                  |
| `onPurchaseRequest { }`                                           | `EncorePurchaseController` at `configure()`                                                                                                     |
| `onPurchaseComplete { }`                                          | [`result.publisher`](../sdk-reference/presentation-result#publisheroutcome) `== PublisherOutcome.Purchased` / `Encore.outcomes`                 |
| `onPassthrough { }`                                               | Inline fallback off `result.claim == null && result.publisher != PublisherOutcome.Purchased`                                                    |
| `Encore.shared.purchaseSubscription(...)` + Play Billing fallback | Removed; your controller owns the purchase, so you already have the result                                                                      |
| `BillingPurchaseResult`                                           | Removed; the transaction belongs to your billing stack                                                                                          |
| `show(activity, scope)` / `show(scope)`                           | `show(activity) { result -> }` / `show { result -> }`                                                                                           |
| `placements.notifyResult` observation                             | `Encore.outcomes` (SharedFlow)                                                                                                                  |

***

## Behavior changes to be aware of

These compile fine but behave differently at runtime:

* **Claim-then-purchase records both funnels.** 1.x collapsed the flow to a single winner. 2.0 carries `advertiser = Claimed(...)` and `publisher = Purchased` side by side, and the result is delivered once, after the purchase resolves (later than 1.x fired its callbacks).
* **Strict mode records the claim, and verification upgrades it.** With [`UnlockMode`](../sdk-reference/configure#unlock-modes)`.Strict`, the record says `advertiser = Claimed(...)` immediately, and in-session verification upgrades it to `Verified(...)`. A claim that ends unverified re-verifies across launches and surfaces as [`PlacementOutcome.StrictUnlockVerified`](../sdk-reference/outcomes) on `Encore.outcomes`; the persistence rules are in [unlock modes](../sdk-reference/configure#unlock-modes).
* **The offer sheet no longer recreates on rotation or dark-mode changes**, so mid-flow configuration changes can't produce spurious results, and a dead flow (killed sheet) resolves as `Presented(dismissal = Interrupted)` instead of wedging `show()` in "already presenting".
* **Gesture dismissal now reports `swipe_dismiss`** in analytics (previously lumped into `close_button`). On the SDUI sheet, which is the default render path, this covers both dragging the sheet down and tapping outside it; on the native fallback sheet only dragging reports it. All other event names and close-reason strings are unchanged on the wire.

<Warning>
  **Backup rules caveat for strict mode.** The SDK ships an include-only Auto Backup allowlist: it includes `com.encorekit.encore.xml` (the cross-install NCL identity file) and excludes its pending-claims file by omission. If your app replaces the SDK's backup rules wholesale (`tools:replace` on the manifest attributes), keep `com.encorekit.encore.xml` included and keep `com.encorekit.encore.pending.xml` out of your include list. Runtime guards back the pending-claims exclusion up either way.
</Warning>

New in 2.x with no migration required: placements can declare a claim-only reward surface (`Encore.placement("streak_complete")`[`.useCase(UseCase.REWARD_USERS)`](../sdk-reference/placement#use-cases)) with per-presentation `headline()` / `subheadline()` copy overrides. The surface fires *after* the user has already done something worth celebrating, and reward presentations never run a purchase and never show the paywall layout.

***

## Verify the migration

<Check>
  Confirm each of these before shipping:
</Check>

1. The project compiles with no references to removed 1.x APIs.
2. If a subscription product is configured for your app, an `EncorePurchaseController` is registered at `configure` time.
3. Every fallback path (your original paywall or flow) runs off the raw facts (`result.claim == null && result.publisher != PublisherOutcome.Purchased`), not a removed callback.
4. If you subscribe to `Encore.outcomes`, the subscription starts at app startup (there is no replay).
5. Test the full flow on a device: present a placement, claim an offer, complete a purchase, and dismiss without acting. Each path should resolve exactly once with the result you expect.

***

If you hit anything this guide doesn't cover, reach out to [admin@encorekit.com](mailto:admin@encorekit.com).
