Skip to main content

Activation Android SDK 1.2.0

1.2.0 builds on 1.1.2. Its headline change is Error Flows: a receipt the SDK can't accept no longer ends the offer flow or reaches your app as an error. Every problem now has a screen of its own that offers the shopper a human review.

warning

This release contains breaking changes. Four ActivationException variants were removed, OffersWallFlow gained two parameters mid-signature, three theming records gained parameters mid-constructor, and hosts must stop bracketing their own scan sessions. See Breaking changes and the Upgrade checklist.

Installation​

Bump the BOM to 1.2.0. You still depend only on activation-bom and activation; everything else is pulled in automatically.

build.gradle.kts
dependencies {
implementation(platform("com.actualplatform:activation-bom:1.2.0"))
implementation("com.actualplatform:activation")
}

The compile target is unchanged from 1.1.2: compileSdk 37 (or higher) is still required, and your app's targetSdk does not need to change. No new dependencies are introduced.

tip

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

Breaking changes​

Four changes require attention when upgrading from 1.1.2. The first is a compile break for any app that branches on receipt errors; the last is a silent runtime break.

Four ActivationException variants removed​

ReceiptAlreadyScanned, ReceiptTooOld, ReceiptSubmissionLimitReached, and ReceiptInvalid are gone. Each of those verdicts now has its own screen inside the SDK, so none of them is an error any more — and none of them reaches onException.

// Before (1.1.2)
onException = { exception ->
when (exception) {
is ActivationException.ReceiptAlreadyScanned -> showDialog("Already scanned")
is ActivationException.ReceiptTooOld -> showDialog("Too old")
is ActivationException.ReceiptSubmissionLimitReached -> showDialog("Limit reached")
is ActivationException.ReceiptInvalid -> showDialog("Invalid receipt")
else -> logError(exception)
}
}

// After (1.2.0) — the SDK owns all four cases; only real failures arrive here
onException = { exception -> logError(exception) }

What remains on ActivationException: CameraScan, Submit, ReceiptSummary, and the Registration hierarchy (Failed, MissingIdentity, MissingDeviceConfiguration) — all unchanged.

Delete the branches for the removed variants; there is nothing to replace them with. If you were showing your own dialog for these cases, remove it — the SDK now shows a screen that also offers the shopper a review request, and your dialog would compete with it.

OffersWallFlow gained two parameters​

onResumeScan and onScanAccepted were inserted directly after onScanReceipt, so positional construction no longer lines up. Use named arguments:

OffersWallFlow(
onScanReceipt = { launchCameraAndAwaitResult() },
onResumeScan = { current -> resumeCameraAndAwaitResult(current) },
onScanAccepted = { finalizeRecognizerSession() },
onContinue = onFinish,
onDismiss = onFinish,
)

Both are optional and default to null, so an integration that already uses named arguments compiles unchanged. Leaving onResumeScan null is a silent behavior change rather than a compile error — see Resuming a scan.

Theming records gained parameters​

Three records gained parameters in the middle of their constructors, so code that builds them by position no longer lines up:

RecordAdded
ActivationAppearance.ReceiptSummary.ColorsSeven post-scan banner color keys
ActivationAppearancefrequencyCap
ActivationTheme.IconsfrequencyCapIcon

Switch to named arguments. Every new key defaults to the theme token its element already renders with, so unset keys change nothing:

Activation.appearance = ActivationAppearance(
receiptSummary = ActivationAppearance.ReceiptSummary(
colors = ActivationAppearance.ReceiptSummary.Colors(
postScanHeaderBackground = 0xFF0B3D5C,
),
),
)

See Theming the new screens.

Hosts no longer manage the scan session​

startScanSession() and endScanSession() must no longer be called around a scan when you use OffersWall or OffersWallFlow. A scan session is now scoped to one scan, and with OffersWall + .activation(true) the camera opens and owns it — but only if no session is already open when the camera starts.

This is a behavioral break, not a compile break — the methods still exist. A session you open yourself is still open when the camera starts, so the camera rides along on it rather than owning it, and a session the camera does not own is never registered with Actual's backend.

// Before (1.1.2)
ActivationClient.instance.startScanSession()
// … present the offer wall, scan …
ActivationClient.instance.endScanSession()

// After (1.2.0) — remove both calls; the camera brackets each scan

The methods remain supported for hosts that drive scanning without an offer wall at all. See Scan sessions are scoped to one scan.

OffersWall.onScanReceipt stays fire-and-forget

The same rule covers the callback itself: launch your camera flow and return, exactly as before 1.2.0. Do not await the launcher result — awaiting holds the session open across the camera launch, which is the same mistake as bracketing it by hand. There is no change to make here if your existing callback already returns immediately.

What's new​

  • Error Flows. A receipt that can't be processed gets a screen offering a human review instead of ending the flow. Seven cases are covered, split across an on-device tier and a server tier. See Error Flows.
  • Resuming a scan instead of restarting it. OffersWallFlow.onResumeScan lets a "Retake photo" add another frame to the same receipt, preserving the blinkReceiptId. See Resuming a scan.
  • Knowing when a scan is final. OffersWallFlow.onScanAccepted fires once no further photo will be requested, so you can close your recognizer session. See Knowing when a scan is final.
  • One scan session per scan. A scanSessionId now covers exactly one receipt: it opens when the camera opens and closes when the camera exits, and no session exists while the shopper is only browsing. See Scan sessions are scoped to one scan.
  • Deferred scan reward. A scan that ends at a review request earns nothing at scan time; the backend grants the reward only if a reviewer accepts. See Deferred scan reward.
  • Theming for the new screens. A sixth per-screen appearance record, ActivationAppearance.FrequencyCap, plus seven receipt-summary banner keys and a new header icon.
  • System back during processing now routes to whichever surface owns the screen — a verdict overlay, the error dialog's own dismissal, or cancelling processing — rather than always cancelling.

Error Flows​

Before 1.2.0, a receipt problem ended the offer flow and surfaced to your app as an ActivationException; it was on you to explain it to the shopper. Now each problem raises a screen that explains what happened and offers to send the receipt to a human reviewer. Dismissing that screen returns the shopper to the offer wall with the flow intact.

Checks run in two tiers. Device-tier checks run before the scan is submitted, so a receipt one of them stops is never registered with the backend — it can never come back as a duplicate either.

ProblemDetectedWhat the SDK showsRetake offered?
Photo can't be readDevice — from ScanReceiptResult.isValidReceiptInvalid-receipt screenYes
Date or total missingDevice — from ScanResultsMissing-fields form; continues to processing on submitYes
Store name missing aloneDeviceNothing — treated as a complete scan—
Purchase date outside the rewards windowDevice (receiptMaxAgeDays) or serverReceipt-too-old screenNo
Submission limit reachedServer — DuplicateDetection.userExceededThresholdFrequency-cap screen naming each limit—
DuplicateServer — DuplicateDetection.isDuplicateReceipt Summary, duplicate banner, rewards suppressed—
Actionable fraud flagServer — isFraudulent and an allow-listed fraudReasonReceipt Summary, invalid banner, rewards suppressed—

Both integration options present these screens. With OffersWall + .activation(true) the camera activity owns them and handles the retake by resuming its own capture session, with no host-app code. With OffersWallFlow the flow composable owns them and calls onResumeScan for the retake.

Two host-app requirements:

  • Declare adjustResize on whichever Activity hosts the flow. The review screens contain text fields and inset themselves from the window's IME insets; without it the keyboard covers the form's input and submit button.

    AndroidManifest.xml
    <activity
    android:name=".MainActivity"
    android:windowSoftInputMode="adjustResize" />
  • Turn test mode off to exercise server verdicts. TestOptions.Test resolves both tiers to accepted. The two device checks that describe the scan itself — unreadable photo, missing field — still apply under test mode; the age check and every server verdict do not.

The rewards window is configured with ActivationClient.receiptMaxAgeDays (default and minimum 14).

For the full behavior see Error Flows; for what the shopper sees in each case, Error Flows Summary and the Error Flows diagram.

Corrections are evidence, not data

When a shopper fills in a missing field, that value travels to the reviewer only — it is never merged into the scan submitted for processing, which is always exactly what the scanner read. A field that was missing at scan time is still missing on the Receipt Summary. Treat a correction as a pending claim in your own accounting, and wait for the webhook.

Resuming a scan instead of restarting it​

Every review screen that offers "Retake photo" is adding a frame to the receipt already in hand, not starting over. onResumeScan is how your app honors that:

import com.actualplatform.activation.OffersWallFlow
import com.actualplatform.activation.ScanReceiptResult

@Composable
fun FullPromotionsFlow(onFinish: () -> Unit) {
OffersWallFlow(
// Suspend until your camera produces a ScanReceiptResult (or null if cancelled).
onScanReceipt = { launchCameraAndAwaitResult() },
// Resume the open recognizer session: another frame for THIS scan,
// keeping its blinkReceiptId.
onResumeScan = { current -> resumeCameraAndAwaitResult(current) },
onContinue = onFinish,
onDismiss = onFinish,
)
}

The updated result routes like a fresh delivery — a now-complete scan is accepted for processing, one still missing a field returns to the review. Return null to cancel, exactly as with onScanReceipt.

Keep the camera inside this composition

onScanReceipt and onResumeScan are suspend functions: the SDK awaits them while OffersWallFlow stays composed. If you push your camera screen onto a navigation back stack that replaces OffersWallFlow, the flow leaves composition, its awaiting coroutine is cancelled, and the result never arrives. Show the camera as an overlay within the same composable, or launch it as a separate Activity via rememberLauncherForActivityResult, which also keeps this composition alive.

warning

Leave onResumeScan null and the flow falls back to onScanReceipt, which starts a fresh scan and changes the blinkReceiptId. Nothing errors, but one receipt then reaches your backend and your webhooks as two unrelated receipts. Implement it if your camera integration can resume a session.

OffersWallFlow requires you to build ScanReceiptResult yourself

ScanReceiptResult takes com.actualplatform.activation.models.ScanResults, a Boolean validity flag, and com.actualplatform.activation.models.Media. The Paper Receipt camera's CameraRecognizerResults.Success returns different types — com.microblink.core.ScanResults and com.microblink.Media — and does not expose a validity flag at all. The Activation SDK ships no public mapper between them, and CameraRecognizerOptions cannot carry an existing session, so a re-launch through CameraRecognizerContract always starts a new blinkReceiptId and can never satisfy onResumeScan.

If you're integrating the Paper Receipt camera, use OffersWall with .activation(true) instead — the camera Activity performs this mapping internally, presents the review screens, and handles retakes on the same receipt with no host-app code. OffersWallFlow fits hosts that already produce Activation-model ScanResults from their own scanning pipeline.

Knowing when a scan is final​

onScanAccepted fires once a completed scan has been accepted for processing and no further photo will be requested for it — at the initial delivery, after a retake recovered the last missing field, or after a single-field review advances to processing. That is the point at which your app can finalize a still-open recognizer session:

OffersWallFlow(
onScanReceipt = { /* … */ },
onResumeScan = { current -> /* … */ },
onScanAccepted = { result ->
// Safe here: no further frame will be requested for this scan.
// Only terminate the view that owns the live session — the underlying
// Recognizer is a process-wide singleton, so terminating a stale view
// poisons an unrelated session.
recognizerView?.terminate()
recognizerView = null
},
onContinue = onFinish,
onDismiss = onFinish,
)

Merchant name may still be absent when this fires — it is verifiable from the photo during review, so it is not required for acceptance. Purchase date and total are.

If you launch the camera through CameraRecognizerContract instead of RecognizerView, the camera Activity owns its own session lifecycle and there is nothing to release — onScanAccepted is then just a signal you can use to clear your own pending state or emit analytics. See RecognizerView lifecycle for the full session contract.

Scan sessions are scoped to one scan​

A scanSessionId now identifies one scan — one trip to the camera and the screens that follow it.

With OffersWall + .activation(true), the camera owns the session: it opens one when it starts — but only if no session is already open — and closes it when it exits, including a cancelled scan that never produced a receipt. That ownership matters, because a session is minted on device and Actual's backend only learns of it from the first request that carries it; the camera's own pipeline makes the session known before any review screen can reference it.

Known issue in 1.2.0

Do not hold a scan session open across the camera launch — neither by calling startScanSession() yourself nor by awaiting the camera result inside OffersWall.onScanReceipt. A session that is already open when the camera starts is one the camera rides along on instead of owning, so nothing registers it. Device-tier review requests — a missing date or total, an unreadable photo, a receipt outside the rewards window — are filed before the receipt is submitted for processing, so that review request becomes the first thing to reference an unregistered session and fails with "Scan session not found". Nothing throws and nothing reaches onException; the shopper simply cannot submit the review. Addressed in a later release.

What that means in practice:

Behavior
Two receipts scanned in one visit to the wallTwo different scanSessionIds, with no session open in between
Receipts per sessionAt most one blinkReceiptId ever appears under one scanSessionId
While the shopper is only browsingNo session exists — the offer-wall fetch carries no scanSessionId
A retake that resumes the same receiptStays in the same session; only a fallback to a genuinely new receipt rotates it

Remove any startScanSession() / endScanSession() calls you had around a scan — see Breaking changes — and leave OffersWall.onScanReceipt fire-and-forget: launch your camera flow and return.

The blinkReceiptId is unaffected: it still identifies one receipt, and every photo captured for that receipt (retakes included) keeps it.

Why this changed

Sessions used to be scoped to the offer wall's lifetime, so several receipts scanned in one visit shared a single id and reward, review, and submission events from different receipts could not be told apart. Scoping a session to one scan makes per-scan reporting possible and stops a stale id from being attached to a later receipt.

ActivationClient.close() now also ends any open session. Previously the id survived close() and was inherited by the next initialize(), so a new SDK lifetime's first offer-wall fetch could carry the previous one's session.

Deferred scan reward​

A scan that ends at a review request earns nothing at scan time. The backend holds the base scan reward and grants it only if a reviewer accepts the receipt, which may be hours or days later.

FlowBase scan reward
Scan accepted normallyPaid immediately
Missing fields, review submittedPaid immediately — this flow continues to processing
Invalid receipt, receipt too old, submission limit, disputed duplicateDeferred — paid only on reviewer acceptance

The amount is unchanged: it is whatever your app configured through ScanReward. Deferred rewards arrive as RewardUpdate webhooks, so design your backend for open-ended reward accrual per blink_receipt_id rather than treating the summary as final.

Theming the new screens​

Activation.appearance gains a sixth per-screen record, ActivationAppearance.FrequencyCap, with nine color keys:

frequencyCapBackground, frequencyCapTitleLabel, frequencyCapDescriptionLabel, frequencyCapIconTint, frequencyCapCardBorder, frequencyCapCardHighlightBorder, frequencyCapCardTitleLabel, frequencyCapCardDescriptionLabel, frequencyCapLimitReachedLabel

ActivationTheme.Icons gains frequencyCapIcon for that screen's header glyph, and ActivationAppearance.ReceiptSummary.Colors gains seven keys for the post-scan banner:

postScanDuplicateBannerBackground, postScanDuplicateBannerTitleLabel, postScanDuplicateBannerDescriptionLabel, postScanDuplicateBannerIcon, postScanDuplicateBannerIconBackground, postScanDuplicateBannerButtonBackground, postScanDuplicateBannerButtonTitle

note

Despite the postScanDuplicateBanner* naming, these seven keys style all three banner cases — duplicate, invalid receipt, and pending review. Every key defaults to the theme token its element already renders with, so unset keys change nothing.

The review forms and success confirmations reuse shared components and follow the global theme rather than exposing their own keys. Full catalogues: Theme Customization and Appearance Customization.

Upgrade checklist​

  • Bump the BOM to 1.2.0.
  • Delete onException branches for ReceiptAlreadyScanned, ReceiptTooOld, ReceiptSubmissionLimitReached, and ReceiptInvalid, along with any dialogs you showed for them.
  • Switch any positional OffersWallFlow(...), ActivationAppearance(...), ActivationAppearance.ReceiptSummary.Colors(...), or ActivationTheme.Icons(...) construction to named arguments.
  • Remove startScanSession() / endScanSession() calls made around a scan when using OffersWall or OffersWallFlow.
  • Leave OffersWall.onScanReceipt fire-and-forget — launch the camera and return, do not await the launcher result. Holding the session open across the camera launch breaks device-tier review requests.
  • Walk a missing-date/total review end-to-end and confirm the review request submits — no "Scan session not found".
  • Verify a scan still earns rewards end-to-end, and that two receipts scanned back-to-back report two different scanSessionIds.
  • Implement OffersWallFlow.onResumeScan so a retake resumes the open recognizer session instead of starting a new scan.
  • Implement OffersWallFlow.onScanAccepted if you need to finalize a recognizer session at the acceptance point.
  • Add android:windowSoftInputMode="adjustResize" to the Activity hosting the offer flow.
  • Confirm your backend treats a review request as a pending claim and credits the shopper only on the resulting RewardUpdate webhook.
  • Walk each error flow on device with TestOptions.Test off, so server verdicts actually fire.