Skip to main content

Activation Android SDK 1.3.1

1.3.1 builds on 1.2.0 and ships together with Paper Receipt SDK 2.5.1. Its changes first shipped in 1.3.0; 1.3.1 is a hotfix that renames the privacy type BlinkPrivacy to Privacy, and it is the version to install. These notes cover everything from 1.2.0 onward. If you are already on 1.3.0, see Upgrading from 1.3.0.

It has three headline changes:

  • Ad consent. Your app now reports each user's privacy choices to the SDK, and the SDK decides — before every ad request — whether to request a personalized ad, a non-personalized ad, or no ad at all.
  • Client-Side Offer Feed. Render the SDK's curated offers in your own UI, and open the SDK's offer details or a whole group when the shopper taps one.
  • Merchant promos. A new kind of offer that qualifies on total spend at a store instead of on specific products.

It also fixes the 1.2.0 known issue where a device-tier review request could fail with "Scan session not found".

warning

This release contains breaking changes. ActivationClient.initialize(privacy) is now required before any other SDK call, it must be paired with Paper Receipt SDK 2.5.1, two public records gained constructor parameters mid-signature, and CouponGoal.spend was removed. See Breaking changes and the Upgrade checklist.

Installation​

Bump the Activation BOM to 1.3.1 and the Paper Receipt BOM to 2.5.1 — the two versions are released as a pair and must be upgraded together (see the Android SDK Version Matrix).

build.gradle.kts
dependencies {
implementation(platform("com.microblink.blinkreceipt:blinkreceipt-bom:2.5.1"))
implementation("com.microblink.blinkreceipt:blinkreceipt-recognizer")
implementation("com.microblink.blinkreceipt:blinkreceipt-camera-ui")

implementation(platform("com.actualplatform:activation-bom:1.3.1"))
implementation("com.actualplatform:activation")
}

The compile target is unchanged from 1.2.0: compileSdk 37 (or higher) is still required, and your app's targetSdk does not need to change. No new dependencies are introduced. As with every release, recompile your app against the new version.

tip

The SDK ships its own consumer ProGuard/R8 rules and is safe under R8 full mode.

Breaking changes​

ActivationClient.initialize(privacy) is required​

The SDK no longer starts itself. ActivationClient.initialize(privacy) is the only way to start it, it takes the user's privacy choices as a Privacy, and it must run before anything reads ActivationClient.instance — reading instance beforehand throws IllegalStateException. Call it from Application.onCreate(), before you build any dependency-injection graph whose singletons reach for the SDK.

import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.ConsentState
import com.actualplatform.activation.Privacy
import com.actualplatform.activation.RdpOwner

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

// New in 1.3.0 — required, and first.
ActivationClient.initialize(
Privacy(
sellOrShareStatus = ConsentState.UNKNOWN, // replace with the user's answers
personalizationStatus = ConsentState.UNKNOWN, // as soon as you have them
disclosureVersion = null,
rdpOwner = RdpOwner.ACTUAL,
)
)

// Unchanged. Order relative to initialize() doesn't matter.
BlinkReceiptSdk.initialize(this, initializeCallback)
}
}

What else to know:

  • The Paper Receipt SDK still supplies device identity. Once both SDKs have started — in either order — the Paper Receipt SDK hands the Activation client its deviceId, clientUserId, and appBundleId, as before. You still set hashedEmail / hashedPhone, rewardCurrency, and scanReward yourself.
  • If you don't call it, Activation stays off. The Paper Receipt SDK keeps working, logs one error, and the Activation features are simply absent. Nothing crashes — but nothing from Activation appears either.
  • Calling it again updates the privacy values on the running client; it does not rebuild it. Assigning ActivationClient.instance.privacy does the same.
  • Privacy values are never persisted. Supply them on every app start, and again whenever the user changes a choice or a different user signs in.
  • After close(), call initialize(privacy) again before using the SDK.

The values themselves — and how they change what ads the SDK requests — are covered in Ad consent below and in full on Android Ad Consent.

Choose rdpOwner deliberately

The samples use RdpOwner.ACTUAL: the SDK keeps Google's app-wide gad_rdp preference in step with the user's answers. Use it only if nothing else in your app writes gad_rdp — two writers of one app-wide key overwrite each other. If something else manages it, use RdpOwner.PUBLISHER instead; the SDK then never writes gad_rdp, and while sellOrShareStatus is UNKNOWN or OPTED_OUT and your app has not set it, the SDK requests no ads. See Choosing rdpOwner.

Paper Receipt SDK 2.5.1 is required​

Activation 1.3.1 and Paper Receipt 2.5.1 coordinate how the Activation client is started, so they must be used together. Paper Receipt 2.5.1 with an older Activation SDK leaves Activation switched off, and an older Paper Receipt SDK with Activation 1.3.1 is not supported. See the Android SDK Version Matrix.

Offer gained parameters​

Offer gained parameters in the middle of its constructor — rank, ctaText, startDate, adUnitHTML, targetUrl, taskType, couponType, currency, redemptionLimit, and excludedFromMerchants — so positional construction no longer lines up. Use named arguments. Most apps never construct Offer; this mainly affects test fixtures.

ActivationAppearance.OffersWall.Colors gained offerRedemptionLabel​

The new key sits in the middle of the constructor, directly after offerTagBackground.

Positional construction compiles — and silently shifts your colors

Every parameter of ActivationAppearance.OffersWall.Colors is a Long?, so a positional call still compiles after the upgrade, but every color after offerTagBackground moves onto the wrong key. Switch to named arguments:

Activation.appearance = ActivationAppearance(
offersWall = ActivationAppearance.OffersWall(
colors = ActivationAppearance.OffersWall.Colors(
offerTagBackground = 0xFFFFE8CC,
offerRedemptionLabel = 0xFFB54708, // new in 1.3.0
),
),
)

CouponGoal.spend removed​

CouponGoal.spend was always null and has been removed. The new CouponGoal.minimumSpend carries the spend threshold of a merchant promo goal. CouponGoal.quantity is still a non-null Int, and reads 0 for a spend-based goal, which has no product quantity.

GoogleSdkInstructions gained requiresRestrictedMode​

GoogleSdkInstructions gained a required requiresRestrictedMode parameter. This only affects code that constructs the model itself, such as test fixtures.

Other model and composable changes​

AdUnit, AdImage, Activations, Placement, ScanResults, OffersWall, and OffersWallFlow gained new parameters at the end, all with defaults. Source code that compiled against 1.2.0 still compiles, but — as with every release — your app must be recompiled against 1.3.1.

System back at the offer wall's root now calls onDismiss​

On OffersWall and OffersWallFlow, a system back press (button or gesture) at the offer wall's root now reports through onDismiss — the same exit the header's back arrow takes. Previously the press went straight to your app's own navigation, which could dismiss the screen hosting the wall. If you pass onDismiss, make sure it closes the wall; with onDismiss null, nothing changes and your app keeps owning back.

What's new​

  • Ad consent. The SDK serves personalized ads only where the user's choices and Actual's assessment of your privacy notice allow it, and restricts — or skips the request — where they don't. See Ad consent.
  • Client-Side Offer Feed. ActivationClient.instance.api.refreshOfferFeed() and offerFeed expose the curated offers as plain models for your own UI; OffersWallDestination opens the SDK's offer details or a group from a tap. See Client-Side Offer Feed.
  • Merchant promos. Spend-based offers with their own card treatment and their own earnings card. See Merchant promos.
  • Merchant-specific scan reward. Actual can configure a different base scan reward for a specific store — including zero — which replaces your ScanReward for receipts from that store. The base scan is reported even if you configure no ScanReward at all (as an amount of 0), so Actual can set the reward entirely on its side. The Receipt Summary and Rewards.ScanFinished always carry the amount that was actually applied. When Actual supplies no amount in time, your configured ScanReward stands; with neither, no scan reward is shown or emitted.
  • Offer image placeholder. A missing or broken product or store image now shows a neutral placeholder on offer cards, list items, and the detail screen, instead of an empty space. The store name beside it falls back to "All Stores" when no merchant is known.
  • Merchant-detection signals. ScanResults.merchantDetection carries how the scanner identified the store, so a misidentified merchant can be traced. The Paper Receipt camera fills it in automatically from 2.5.1; if you build ScanResults yourself for OffersWallFlow, it's optional.
  • New model fields. AdImage gains aspectRatio (for example "1:1"), backgroundFill (#RRGGBB, the color to paint behind merchant-promo artwork), and fit (for example "contain"), each null when the image was not normalized. Placement gains its numeric id; an offer-wall placement without one is skipped, with a warning, by both the offer wall and the offer feed. AdUnit gains the raw clipExpirationDate string.

From 1.3.0, every Google ad request the SDK makes — the loading-screen ad and the rewarded ads behind boosts — is decided by the user's privacy choices. The full guide is Android Ad Consent; this is the summary for upgrading.

What you supply. A Privacy with four required fields:

FieldValuesGoverns
sellOrShareStatusOPTED_OUT, NOT_OPTED_OUT, UNKNOWNRestricted data processing
personalizationStatusOPTED_OUT, NOT_OPTED_OUT, UNKNOWNNon-personalized requests
disclosureVersionISO date of the privacy notice the user saw, or nullActual's assessment of your notice
rdpOwnerPUBLISHER, ACTUALWho writes Google's app-wide gad_rdp preference

UNKNOWN restricts, exactly like OPTED_OUT. Values are held in memory only.

What the SDK decides. Before every ad request, the SDK combines your values with Actual's assessment of the privacy notice you reported, and picks one of three outcomes:

OutcomeWhen
Personalized requestBoth statuses are NOT_OPTED_OUT, Actual doesn't require restricted mode, and restricted processing isn't otherwise in effect.
Non-personalized requestpersonalizationStatus is OPTED_OUT or UNKNOWN, or restricted processing applies — and, where restricted processing is required, gad_rdp is in place.
No requestRestricted processing is required but gad_rdp isn't set: under PUBLISHER because your app hasn't set it, under ACTUAL because the SDK couldn't write it.

Restricted data processing and a personalized request are never combined.

How ads now behave in the SDK:

SurfaceBefore 1.3.0From 1.3.0
Loading-screen adAlways requestedRequested only when consent allows; otherwise no ad is shown and processing continues
Boost (rewarded ad) on the Receipt SummaryOffered whenever an ad preloadedNever offered when consent forbids the request — the button doesn't appear
Rewarded ad preloaded before the user changed a choiceShown as preloadedRe-checked on tap: dropped, or requested again under the current values
Changing a privacy value—Applies from the next ad request; nothing is latched for the session
Errors—None — a refused request looks like an ad that didn't fill, and nothing reaches onException

What to set up in Google Ad Manager. Each request now carries the decision as custom targeting — npa, rdp, adid_state, sdk_version, and app_version. Register these as predefined keys in your Ad Manager network to see them in reports; see Ad Manager reporting keys. The advertising identifier itself is never read, logged, or sent — only whether it is available.

What the SDK reports. Every decision and every refused request is reported through the SDK's existing event pipeline as ad_consent_decision and ad_suppressed, carrying the inputs and the values applied, so any ad request can be reconstructed from its event. The Google ad events were renamed at the same time — see Google ad events renamed. The SDK also sends your disclosureVersion as the x-actual-disclosure-version header on the requests that return ad instructions, and omits the header when you supply null.

Testing. TestOptions.Test now correctly registers your device as a Google Mobile Ads test device, so the loading-screen ad and the rewarded ads are served as test requests.

Client-Side Offer Feed​

Render the SDK's curated offers in your own UI, then hand a tapped offer or group back to the SDK:

val api = ActivationClient.instance.api

// Your screen decides when to load. null = not loaded yet; empty groups = nothing to show.
val feed by api.offerFeed.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { api.refreshOfferFeed() }

var destination by remember { mutableStateOf<OffersWallDestination?>(null) }

feed?.groups?.forEach { group ->
MyOfferCarousel(
title = group.title,
offers = group.offers,
onOfferTap = { offer -> destination = OffersWallDestination.OfferDetails(offer) },
onSeeAllTap = { destination = OffersWallDestination.Group(group) },
)
}

destination?.let {
OffersWall(initialDestination = it, onScanReceipt = { scanAndAwait() }, onDismiss = { destination = null })
}
  • refreshOfferFeed() returns OfferFeedResult.Success(feed) or Failure(OfferFeedError) — NOT_REGISTERED, REQUEST_FAILED, or DISABLED (the feed is turned off for your app; hide your UI and don't retry).
  • Each OfferSummary is a ProductCoupon (with minQuantity) or a MerchantPromo (with minimumSpend, currency, redemptionLimit, excludedMerchants, clipExpirationDate). Reward values are already in your reward currency. Keep an else branch when matching variants.
  • offerFeed stays current on its own: it re-emits when the shopper clips an offer inside the SDK, when you change rewardCurrency, and when the offer wall reloads — and resets to null when the user's identity changes.
  • initialDestination is a new optional parameter on both OffersWall and OffersWallFlow. Destinations open on top of the offer wall, so closing them leaves the shopper in the wall. A destination the loaded offers can't resolve opens the wall at its root — for an offer, with a "This offer is currently unavailable." toast instead of the detail sheet.
  • Dates are typed: OfferSummary.endDate is a CalendarDate (year, month, day — no time zone) and MerchantPromo.clipExpirationDate a kotlin.time.Instant. A value the SDK can't parse becomes null and is logged.
  • An offer the feed can't describe accurately — an unrecognized couponType, a promo without a spend goal, or a store restriction or exclusion naming a store missing from the response — is left out of the feed, with a warning in the SDK log.

The full guide — data model, destinations, errors, and a complete example — is Android Client-Side Offer Feed.

Merchant promos​

Merchant promos are a new offer type that qualifies on total spend at a store — "spend $25 at this store" — instead of on specific eligible products. They appear in the offer wall alongside existing product offers, with no integration work.

What shoppers see:

  • A "SPEND $X" tag in place of "BUY N" on offer cards and list items. Thresholds round up to a whole unit and group thousands — a $10.20 threshold reads "SPEND $11", never understating what qualifies.
  • A dashed border that sets merchant-promo cards apart from product offers.
  • A redemption-period label ("30d left") next to the retailer name when the promo has a redemption window, colored by the new offerRedemptionLabel appearance key (defaults to the theme's warning color).
  • Its own earnings card. A qualifying scan earns the promo's reward immediately, shown in the Receipt Summary's "Your Earnings" as Merchant Boost — Scan any <Store> receipt — instead of the product line used for product rewards.
  • Store-aware lists. The offer wall's per-store lists leave out an offer that is explicitly excluded at that store.

What your code can read on Offer:

MemberMeaning
couponTypeCouponType.UPC (product offer), CouponType.MERCHANT_PROMO, or CouponType.UNKNOWN (a type this SDK version doesn't recognize).
isMerchantPromoShorthand for couponType == CouponType.MERCHANT_PROMO.
minimumSpendThresholdThe lowest spend threshold across the offer's goals — a tiered promo advertises its cheapest entry point.
currencyCurrency of the spend threshold.
redemptionLimitRedemptionLimit(count, periodDays) — how many redemptions over a rolling window; periodDays == null means a lifetime cap.
excludedFromMerchantsStores the offer is explicitly not valid at.
appliesToBanner(bannerId)One eligibility check combining the store restrictions and exclusions.

CouponGoal.minimumSpend carries each goal's spend threshold. An offer that has both a quantity goal and a spend goal keeps its "BUY N" tag.

Changed​

OffersWall.onScanReceipt should now suspend for the whole scan​

OffersWall opens a scan session when the shopper taps "Scan Receipt" and closes it when onScanReceipt returns. In 1.2.0, the docs asked you to launch the camera and return immediately, to work around the known issue below. That issue is fixed in 1.3.0, so the callback should now suspend until the scan finishes — the session then spans the whole scan, and the camera joins it instead of opening a second one:

import kotlinx.coroutines.CompletableDeferred

@Composable
fun PromotionsScreen(onBack: () -> Unit) {
var scanCompletion by remember { mutableStateOf<CompletableDeferred<Unit>?>(null) }

val launcher = rememberLauncherForActivityResult(CameraRecognizerContract()) { result ->
if (result is CameraRecognizerResults.Exception) {
Log.e("Scan", "Error: ${result.exception}")
}
// The camera — loading, any review screen, summary, rewards — is done.
scanCompletion?.complete(Unit)
scanCompletion = null
}

OffersWall(
onScanReceipt = {
val completion = CompletableDeferred<Unit>()
scanCompletion = completion
launcher.launch(cameraRecognizerOptions)
completion.await() // suspend until the camera returns
},
onDismiss = onBack,
)
}

A callback that still returns immediately keeps working in 1.3.1, but each scan then opens and immediately closes an extra, empty session. OffersWallFlow is unaffected: its onScanReceipt already returns the scan result.

Receipts with no identifiable merchant​

When no store can be identified for a receipt — after every merchant lookup has come up empty — the Receipt Summary now says so instead of paying out against a trip nobody can attribute. The merchant reads ---, the total reads zero, and the base scan reward, qualified promotions, boosts, inline rewards, and banked boost credit are all withheld. The scanned items still list under "Your Receipt". No error screen or banner is shown: nothing is wrong with the receipt, there's just no merchant to reward against.

The base scan is reported once per receipt​

Each receipt now reports its base scan to Actual exactly once, as soon as processing starts — including receipts whose verdict then withholds rewards (duplicate, unreadable, too old, or over a submission limit). For those, Actual's backend decides what the scan earns. Treat the RewardUpdate webhook as the source of truth for crediting the shopper.

Offer values match the Receipt Summary​

The Offer Wall and Offer Details now show a promotion's reward at its full value — the same amount the Receipt Summary's "Your Earnings" shows for it. Previously both scaled the displayed value by RewardCurrency.userPayoutPercentage and understated it. userPayoutPercentage still applies to boosts and inline rewards. See Android Reward Currency.

Promotion payouts​

A promotion that covers several scanned products is now credited once, at the amount actually awarded, and the total is rounded once rather than per row. A promotion with no usable payout is not credited, and its product stays under "Your Receipt". Rewards.Promotion carries the corrected amount.

When two promotions qualify the same scanned product, the last one listed now claims it, matching iOS. Android previously credited the first.

Google ad events renamed​

The Google ad events the SDK reports follow the new event contract:

Before 1.3.0From 1.3.0
GAM_AD_REQUESTEDad_request
GAM_AD_LOADED, GAM_AD_FAILEDad_response

Both carry the consent fields of the decision behind their request, and the old adType, loadedAdType, and error fields are gone, so a loaded ad and a failed load produce the same ad_response. ad_request is reported only when a request actually goes out — not for a load that consent refused, a rewarded ad served from cache, or a load that failed before any request was made. ad_response is reported once per request; a banner's automatic refreshes are not reported. Any dashboard keyed on the old names sees no new rows from 1.3.0 on.

RewardPoint moved module​

RewardPoint is now declared in activation-models, so the feed models can carry it. Its package and members are unchanged, so your code compiles as before.

Fixed​

  • "Scan session not found" on device-tier reviews. The 1.2.0 known issue is fixed: every scan session is now known to Actual's backend before any review request can reference it, so missing-field, unreadable-receipt, and too-old review requests submit reliably.
  • Recovering from an authentication hiccup. A 401 Invalid API token no longer ends the shopper's session: the SDK re-registers, retrying up to three times with backoff, and replays the request — now with its body intact, so a replayed POST is no longer rejected.
  • Switching users. Changing the user's identity now clears cached offers and ends in-flight offer requests immediately, so one user's offers can never appear for the next — on the offer wall or in the offer feed. Every request now goes out as the identity current when it was made. Work started as one user (a scan submission, coupon clip, review request, and so on) is dropped rather than sent as the next one: a dropped clip shows the offer wall's clip-error message, and a dropped review request ends the scan flow with ActivationException.Submit.
  • Closing the client. Work still pending when ActivationClient.close() is called no longer carries over into, or fails inside, the next client. startScanSession(), endScanSession(), and setting sessionRefreshInterval on a closed client do nothing.
  • Offers with a spend-only goal. An activations response containing a merchant-promo coupon with no quantity no longer fails to decode as a whole, which blanked the offer wall.
  • Base scan reported twice. Filling in a missing date or total no longer reports the base scan twice for the same receipt.
  • Review rewards after closing the flow. An accepted review's reward report now completes even if your app closes the scan flow while it is in flight.
  • Offer wall reloads. A failed reload no longer blanks the offers already on screen, and reopening the wall no longer shimmers over — or blocks taps on — content it is already showing.
  • Stale sheets. Reopening the offer wall no longer re-opens a detail or stores sheet left open by its previous presentation.
  • Calendar picker cut off its last weekday column. On a typical phone, the date picker opened only as wide as its date field, so the right-most column (Sunday, on Monday-first locales) showed as a sliver and its dates couldn't be tapped. The picker now opens at full width, centered on its field, in the Missed Earnings Receipt Date dialog and in the missing-date and too-old review forms. On screens narrower than the picker, the day cells shrink slightly so all seven columns still fit.
  • Test ads. TestOptions.Test now registers the device as a Google Mobile Ads test device as intended, so ad requests are flagged as test requests.
  • Ad reporting on the loading screen. Ad outcomes on the loading screen are now reported accurately.

Upgrading from 1.3.0​

If you already run 1.3.0, the only change in 1.3.1 is a hotfix rename.

BlinkPrivacy is renamed to Privacy​

com.actualplatform.activation.BlinkPrivacy is now com.actualplatform.activation.Privacy. Its fields, constructor, and behaviour are unchanged, as are ConsentState and RdpOwner. Code that names BlinkPrivacy no longer compiles, so update the import and every place that names the type: the value you pass to ActivationClient.initialize(privacy), any assignment to ActivationClient.instance.privacy, and your own functions or properties typed with it.

import com.actualplatform.activation.Privacy // was: BlinkPrivacy

ActivationClient.initialize(
Privacy( // was: BlinkPrivacy(
sellOrShareStatus = ConsentState.UNKNOWN,
personalizationStatus = ConsentState.UNKNOWN,
disclosureVersion = null,
rdpOwner = RdpOwner.ACTUAL,
)
)

The SDK's consumer rules already keep Privacy. If your own keep rules name com.actualplatform.activation.BlinkPrivacy, rename them too. Then bump the Activation BOM to 1.3.1. Of the Upgrade checklist, only the "From 1.3.0" items apply to you.

Upgrade checklist​

From 1.2.0:

  • Bump the Activation BOM to 1.3.1 and the Paper Receipt BOM to 2.5.1.
  • Call ActivationClient.initialize(privacy) first in Application.onCreate(), before any DI graph that touches the SDK.
  • Map your consent flow's answers into Privacy, supply them on every launch, and assign ActivationClient.instance.privacy whenever they change or another user signs in.
  • Choose rdpOwner deliberately. Under PUBLISHER, set gad_rdp whenever restricted processing is required — otherwise no ads are requested.
  • Register npa, rdp, adid_state, sdk_version, and app_version as predefined custom-targeting keys in Google Ad Manager.
  • Move any dashboards or alerts keyed on GAM_AD_REQUESTED, GAM_AD_LOADED, or GAM_AD_FAILED to ad_request and ad_response.
  • Switch any positional ActivationAppearance.OffersWall.Colors(...) or Offer(...) construction to named arguments.
  • Replace reads of CouponGoal.spend with CouponGoal.minimumSpend.
  • Make OffersWall.onScanReceipt suspend until the camera returns.
  • If you pass onDismiss, confirm a system back press at the offer wall's root closes the wall the way you expect.
  • After close(), call initialize(privacy) again before reusing the SDK.
  • Walk a missing-date/total review end-to-end and confirm it submits.
  • Verify the loading-screen ad and the Boost button for each consent combination your app can produce, with TestOptions.Test on.
  • Optionally adopt the Client-Side Offer Feed.

From 1.3.0:

  • Bump the Activation BOM to 1.3.1.
  • Replace every BlinkPrivacy with Privacy, in imports, constructor calls, type declarations, and your own keep rules.