Skip to main content

Activation SDK — Expo (CNG) Integration Guide (Android)

This guide covers integrating the Activation SDK into a React Native application on Android using Expo with Continuous Native Generation (CNG). Unlike a bare React Native setup where you manually edit native files, CNG automates all native configuration through an Expo config plugin that runs at prebuild time.


Quick Start​

If you want to get up and running fast, here's the minimum path:

  1. Set up environment variables — Add ANDROID_BLINK_LICENSE_KEY, ANDROID_PROD_INTEL_KEY, and ANDROID_GAD_APP_ID to your .env file (Section 2)
  2. Configure the plugin — Set your license keys, GAM App ID, reward currency, and SDK versions in app.config.js (Section 4.2)
  3. Copy and link the module — Copy the entire modules/blink-engage/ directory from this repo into your project, then add "blink-engage": "file:./modules/blink-engage" to your package.json dependencies (Section 3)
  4. Run prebuild — npx expo prebuild --platform android to generate native projects with injected metadata (Section 14)
  5. Set user identity — Call setUser() after login with at least an email or phone (Section 7)
  6. Render the offer wall — Add <OfferWallView style={{ flex: 1 }} /> to a screen (Section 7)
  7. Listen for rewards — Subscribe with addRewardListener() to show earned rewards in real time (Section 8)
  8. Set up webhooks — Configure your backend to receive RewardUpdate events for authoritative reward tracking (Section 9)

For development, enable test mode (test promotions and Google Ad Manager test ad units) by setting android.testOptions: ['Test'] in the plugin config — see Section 12.

The rest of this guide provides the full architecture, implementation details, and reference material.


Table of Contents​

  1. Architecture Overview
  2. Prerequisites
  3. Project Structure
  4. Expo Config Plugin — Automated Native Setup
  5. Native Module (Android)
  6. JavaScript Bridge (Expo Modules API)
  7. Usage in React Native
  8. Handling Rewards (On-Device)
  9. Server-Side Rewards (Webhooks) (Backend)
  10. Error Flows
  11. Missed Earnings (Backend)
  12. Debug Mode
  13. Cleanup & Teardown
  14. Building & Prebuilding
  15. Configuration Reference
  16. Integration Checklist
  17. Troubleshooting

1. Architecture Overview​

In a bare React Native project, you manually:

  • Edit build.gradle to add Maven repos and dependencies
  • Edit AndroidManifest.xml to add license keys and AdMob metadata
  • Create a native module with ReactContextBaseJavaModule
  • Create a Compose Activity to host the offer wall
  • Register packages in MainApplication

With Expo + CNG, all of this is automated:

app.config.js ← Plugin props (license keys, AdMob ID, etc.)
│
▼
blink-engage/plugin/src/index.ts ← Config plugin (runs at prebuild)
│
└─► AndroidManifest.xml ← Injects meta-data entries
│
▼
modules/blink-engage/android/ ← Expo native module (Kotlin)
├── BlinkEngageModule.kt ← Module definition (events, functions)
├── BlinkEngagePackage.kt ← SDK initialization (lifecycle listener)
├── OfferWallView.kt ← Embedded Compose offer wall
└── build.gradle ← Module dependencies
│
▼
modules/blink-engage/src/ ← TypeScript bridge (Expo Modules API)
├── index.ts ← Public API (setUser, showOfferWall, etc.)
├── BlinkEngageModule.ts ← requireNativeModule('BlinkEngage')
├── OfferWallView.tsx ← requireNativeViewManager('BlinkEngage')
└── types.ts ← TypeScript interfaces

Key differences from bare React Native:

  • No separate Activity — the offer wall is embedded as a native view (ExpoView + ComposeView), not launched via startActivityForResult
  • No manual MainApplication edits — SDK initialization happens through ApplicationLifecycleListener
  • No NativeModules — uses Expo Modules API (requireNativeModule / requireNativeViewManager)
  • No manual Gradle/Manifest edits — the config plugin injects everything at prebuild time

2. Prerequisites​

  • Expo SDK 55+ (React Native 0.83+)
  • Android minSdk 24+, compileSdk 37+
  • A Paper Receipt SDK / Activation SDK pair from the same row of the Android SDK Version Matrix
  • Kotlin 2.2 or newer, with 2.3.x recommended (set via expo-build-properties or the root build.gradle). The Activation SDK is built with Kotlin 2.3, and a Kotlin compiler can only read metadata from one minor version above itself, so older versions fail with an "incompatible version of Kotlin" error. See the Android Activation Prerequisites for the full requirements.
  • Jetpack Compose enabled (the module's build.gradle handles this)
  • A valid BlinkReceipt / Activation SDK license key. A key is bound to the package name it was issued for (the applicationId on Android, the bundle identifier on iOS), and the SDK rejects it silently if they differ — so every build variant or flavor with a different applicationId needs its own key.
  • A Google Ad Manager (GAM) account configured for ad monetization (see Section 12 for test ad setup)
  • An HTTPS server endpoint with valid SSL certificate to receive webhook POST requests (see Section 9)
  • Environment variables configured in .env:
    • ANDROID_BLINK_LICENSE_KEY — BlinkReceipt license key
    • ANDROID_PROD_INTEL_KEY — BlinkReceipt Product Intelligence key (optional, enables PI features)
    • ANDROID_GAD_APP_ID — Google AdMob Application ID (Android-specific; iOS uses GAD_APP_ID)

3. Project Structure​

Distribution status: blink-engage is not yet published as a standalone module or npm package. For now, integrators copy the entire modules/blink-engage/ directory from this repository into their own project (under modules/) and link it locally via package.json. Publishing it as an installable npm package is planned future work; once that lands, integrators will be able to npm install blink-engage instead of copying files.

The SDK integration lives as a local Expo module inside the monorepo:

modules/blink-engage/
├── package.json # Module package — linked from root
├── expo-module.config.json # Tells Expo which native classes to load
├── plugin/
│ ├── package.json # Plugin package — build tooling
│ ├── tsconfig.json # TypeScript config for plugin compilation
│ └── src/
│ └── index.ts # Config plugin (prebuild-time native setup)
├── src/ # TypeScript bridge
│ ├── index.ts # Public API
│ ├── BlinkEngageModule.ts # Native module loader
│ ├── OfferWallView.tsx # Native view wrapper
│ └── types.ts # Type definitions
└── android/
├── build.gradle # Module-level dependencies + Compose
└── src/main/java/expo/modules/blinkengage/
├── BlinkEngageModule.kt # Expo module definition (JS ↔ native bridge)
├── BlinkEngagePackage.kt # SDK initialization (runs on app start)
└── OfferWallView.kt # Compose-based offer wall view (the big one)

Module package.json​

Create file: modules/blink-engage/package.json

This defines the local Expo module as an npm package. The "main" and "exports" fields point to the TypeScript source — Expo's module system handles compilation at build time:

{
"name": "blink-engage",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./plugin": "./plugin/build/index.js"
},
"scripts": {
"build": "expo-module build",
"prepare": "cd plugin && npm ci && npm run build"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
},
"devDependencies": {
"expo-module-scripts": "^4.0.0"
}
}

Key things to note:

  • "exports" has two entries: "." for the runtime JS code, and "./plugin" for the config plugin (used by expo prebuild)
  • "prepare" script builds the config plugin automatically when you run npm install
  • peerDependencies are satisfied by your app — no need to install them separately

Linking the Module​

In package.json at the project root, add the module as a local dependency:

{
"dependencies": {
"blink-engage": "file:./modules/blink-engage"
}
}

Then run npm install — this links the local module and triggers its prepare script (which builds the config plugin).

Plugin package.json and tsconfig.json​

Create file: modules/blink-engage/plugin/package.json

The config plugin is a separate TypeScript package that compiles to CommonJS (required by Expo's prebuild system):

{
"name": "blink-engage-plugin",
"version": "1.0.0",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"prepare": "npm run build"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"expo": "*"
}
}

Create file: modules/blink-engage/plugin/tsconfig.json

{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "./build",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

The plugin compiles from plugin/src/index.ts → plugin/build/index.js. This compiled output is what expo prebuild executes.

Expo Module Config​

Create file: modules/blink-engage/expo-module.config.json

This is the file that tells Expo's autolinking system which native classes to load:

{
"platforms": ["ios", "android"],
"android": {
"modules": ["expo.modules.blinkengage.BlinkEngageModule"],
"packages": ["expo.modules.blinkengage.BlinkEngagePackage"]
},
"plugin": "./plugin/build/index.js"
}

What each field does:

  • modules — loads BlinkEngageModule as the native module (the JS ↔ native bridge from Section 5.3)
  • packages — loads BlinkEngagePackage as a lifecycle package (SDK initialization from Section 5.2)
  • plugin — points to the compiled config plugin that expo prebuild will execute

4. Expo Config Plugin — Automated Native Setup​

The config plugin (modules/blink-engage/plugin/src/index.ts) runs during expo prebuild and modifies the generated native project. It does three things on Android:

  1. Injects <meta-data> entries into AndroidManifest.xml (license keys, GAM App ID, reward configuration, test options).
  2. Writes modules/blink-engage/android/sdk-versions.properties so the module's build.gradle can resolve the right Activation / BlinkReceipt artifact versions.
  3. Copies any themeIcons listed in app.config.js into android/app/src/main/res/drawable/ with the snake_case names the SDK expects.

Maven repositories for the SDK are declared in the module's own build.gradle (see Section 5.1), so the plugin does not touch the Gradle files.

4.1 AndroidManifest Metadata Injection​

The plugin injects required <meta-data> entries into the <application> tag:

const withBlinkEngageAndroidManifest: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withAndroidManifest(config, (config) => {
const mainApplication = config.modResults.manifest.application?.[0];
// ...
// BlinkReceipt license key + optional Product Intelligence key
addMetaData('com.microblink.LicenseKey', props.android.licenseKey);
if (props.android.prodIntelKey) {
addMetaData(
'com.microblink.ProductIntelligence',
props.android.prodIntelKey,
);
}

// Google Mobile Ads Application ID (per-platform — Android uses its own AdMob app id)
addMetaData(
'com.google.android.gms.ads.APPLICATION_ID',
props.android.gadAppId,
);

// Activation SDK reward configuration (read by BlinkEngagePackage.kt at runtime).
// BlinkEngagePackage.kt assembles these into a single RewardCurrency object.
addMetaData(
'com.blinkengage.REWARD_CURRENCY_NAME',
props.rewardCurrencyName || 'points',
);
if (props.currencyCode) {
addMetaData('com.blinkengage.REWARD_CURRENCY_CODE', props.currencyCode);
}
addMetaData(
'com.blinkengage.REWARD_CURRENCY_CODE_POSITION',
props.currencyCodePosition || 'Leading',
);
addMetaData(
'com.blinkengage.REWARD_CURRENCY_PER_DOLLAR',
String(props.rewardCurrencyPerDollar ?? 100),
);
// 0–100 percentage; BlinkEngagePackage.kt divides by 100 for the SDK's 0–1 fraction.
addMetaData(
'com.blinkengage.USER_PAYOUT_PERCENTAGE',
String(props.userPayoutPercentage ?? 100),
);
addMetaData(
'com.blinkengage.REWARD_LABEL_STYLE',
props.rewardLabelStyle || 'CurrencyImage',
);
addMetaData(
'com.blinkengage.REWARD_MESSAGING_STYLE',
props.rewardMessagingStyle || 'CurrencyName',
);
addMetaData(
'com.blinkengage.REWARD_ROUNDING',
props.rewardRounding || 'Whole',
);
if (props.rewardImageLocations?.length) {
addMetaData(
'com.blinkengage.REWARD_IMAGE_LOCATIONS',
props.rewardImageLocations.join(','),
);
}
addMetaData(
'com.blinkengage.BASE_REWARD',
String(props.baseReward ?? 10),
);

// Test options (comma-separated, e.g. "Test") — development only
if (props.android.testOptions?.length) {
addMetaData(
'com.blinkengage.TEST_OPTIONS',
props.android.testOptions.join(','),
);
}
// ...
});
};

This means the reward configuration in app.config.js is the single source of truth for both iOS and Android. The plugin writes these values into AndroidManifest.xml as <meta-data> entries, which BlinkEngagePackage.kt reads at runtime.

Escape hatch — disable auto-init. The Activation SDK auto-initializes via androidx.startup, triggered internally by BlinkReceiptSdk.initialize(). If you need to opt out (unusual), set android.engageSdkAutoInit: false and the plugin will add a tools:node="remove" entry that strips ActivationInitializer from the InitializationProvider. Recommended default is true.

4.2 Plugin Props in app.config.js​

The plugin is registered with its configuration in app.config.js. Props are grouped into platform-specific (ios, android) and shared blocks:

plugins: [
[
'blink-engage/plugin',
{
ios: {
licenseKey: process.env.IOS_BLINK_LICENSE_KEY || '',
prodIntelKey: process.env.IOS_PROD_INTEL_KEY,
sdkVersion: '1.1.1',
gadAppId: process.env.GAD_APP_ID || '', // iOS AdMob app id
debugModeEnabled: false,
},
android: {
licenseKey: process.env.ANDROID_BLINK_LICENSE_KEY || '',
prodIntelKey: process.env.ANDROID_PROD_INTEL_KEY,
gadAppId: process.env.ANDROID_GAD_APP_ID || '', // Android AdMob app id
activationVersion: '1.2.0', // com.actualplatform:activation
blinkReceiptVersion: '2.2.0', // com.microblink.blinkreceipt:*
engageSdkAutoInit: true,
testOptions: ['Test'], // development only — omit for production
},

// Shared (both platforms)
rewardCurrencyName: 'points', // max 8 chars; blank → "points"
currencyCode: '$', // optional symbol/code; omit to disable code styles
currencyCodePosition: 'Leading', // 'Leading' | 'Trailing'
rewardCurrencyPerDollar: 100, // 100 reward units = $1
userPayoutPercentage: 100, // 0–100; native ÷100 → clamped 0.4…1.0
rewardLabelStyle: 'CurrencyImage', // 'CurrencyImage' | 'CurrencyName' | 'CurrencyCode'
rewardMessagingStyle: 'CurrencyName', // 'CurrencyName' | 'CurrencyCode' | 'NoAmount'
rewardRounding: 'Whole', // 'Whole' | 'Decimal'
// Surfaces that show the coin icon; omit for all four.
rewardImageLocations: ['OfferWallItem', 'ReceiptTotalReward', 'ReceiptBoost', 'ReceiptTask'],
baseReward: 10,
themeIcons: {
offerRewardIcon: './assets/icons/coin.png',
postScanSuccessIcon: './assets/icons/coin2.png',
postScanBoostDefaultIcon: './assets/icons/boost.png',
},
},
],
// ... other plugins
],

The full prop reference is in Section 15.


5. Native Module (Android)​

For React Native developers: This section provides the complete Kotlin files that make up the native module. You don't need to be a Kotlin expert — the code is copy-paste-ready, with each file explained in plain English. You'll create 3 Kotlin files and 1 Gradle file, each described below with its exact file path.

Kotlin / Android concepts you'll encounter​

If you've only worked in JavaScript/TypeScript, here's a quick reference for the Android-specific terms used in this section:

TermWhat it means (RN analogy)
ExpoViewExpo's base class for native views — similar to creating a native UI component that renders in your RN layout
ComposeViewA bridge that lets Jetpack Compose UI (Android's modern UI toolkit) render inside a traditional Android View. Think of it as a container that translates between two rendering systems
FrameLayoutA simple container view (like a <View> in RN) that holds one child and fills the available space
suspend functionKotlin's equivalent of an async function — it can pause and resume without blocking the thread
ApplicationLifecycleListenerHooks into the app's startup — runs code when the app launches, before any screen renders
rememberLauncherForActivityResultLaunches another screen (like the camera) and receives the result when it finishes — similar to startActivityForResult in bare Android
LaunchedEffectRuns a side effect when a Compose UI first appears — similar to useEffect(() => {}, []) in React
objectA Kotlin singleton — one shared instance, like a module-level constant in JS
sealed classA closed set of subtypes — like a TypeScript union type (type Reward = ScanFinished | Promotion | Boost)

5.1 Module Dependencies​

Create file: modules/blink-engage/android/build.gradle

This Gradle file declares all native dependencies. The Activation SDK and BlinkReceipt SDK are published to Maven Central and the Microblink Maven repository. SDK versions are not hardcoded here — they're read from sdk-versions.properties, which the config plugin writes at prebuild time from the android.activationVersion / android.blinkReceiptVersion props (see Section 4.2). Fallback defaults are provided so Android Studio can sync without running prebuild first.

// The Compose compiler Gradle plugin applied below is not on a library module's classpath by default.
// Put it there, at the same version as the project's Kotlin Gradle plugin.
buildscript {
def kotlinVer = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : '2.1.20'
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:${kotlinVer}"
}
}

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'

android {
namespace "expo.modules.blinkengage"
compileSdkVersion 36

kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.majorVersion
freeCompilerArgs += ["-Xskip-metadata-version-check"]
}

buildFeatures {
compose true
}
}

// SDK versions from sdk-versions.properties (generated by blink-engage config plugin during prebuild).
def sdkVersions = new Properties()
def sdkVersionsFile = file("sdk-versions.properties")
if (sdkVersionsFile.exists()) {
sdkVersionsFile.withInputStream { sdkVersions.load(it) }
}
def activationVer = sdkVersions.getProperty('activation_version', '1.1.0')
def blinkReceiptVer = sdkVersions.getProperty('blinkreceipt_version', '2.2.0')

repositories {
mavenCentral()
maven { url "https://maven.microblink.com" }
}

dependencies {
implementation project(':expo-modules-core')

// Required for ActivityResultContract and rememberLauncherForActivityResult
implementation "androidx.activity:activity-ktx:1.9.0"
implementation "androidx.activity:activity-compose:1.9.0"

// Compose — required for OffersWall (Compose-based offer wall)
implementation platform("androidx.compose:compose-bom:2024.06.00")
implementation "androidx.compose.runtime:runtime"
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.foundation:foundation"
implementation "androidx.compose.material3:material3"

// Activation SDK
implementation("com.actualplatform:activation:${activationVer}")

// BlinkReceipt SDK
implementation("com.microblink.blinkreceipt:blinkreceipt-camera-ui:${blinkReceiptVer}")
implementation("com.microblink.blinkreceipt:blinkreceipt-core:${blinkReceiptVer}")
implementation("com.microblink.blinkreceipt:blinkreceipt-recognizer:${blinkReceiptVer}")
}

The plugin writes modules/blink-engage/android/sdk-versions.properties like this on every prebuild:

# Auto-generated by blink-engage config plugin. Do not edit manually.
activation_version=1.1.0
blinkreceipt_version=2.2.0

To upgrade the SDK, bump the versions in app.config.js (under android.activationVersion / android.blinkReceiptVersion) and re-run expo prebuild. No Gradle edits needed.

Compose compiler plugin: the buildscript block at the top of this file is what puts org.jetbrains.kotlin.plugin.compose on the build classpath. Without it, Gradle fails with "Plugin with id 'org.jetbrains.kotlin.plugin.compose' not found". The plugin's version must equal the Kotlin Gradle plugin version your project uses, and the block does nothing harmful if your root build.gradle already declares the same plugin.

Kotlin version resolution: The Activation SDK may be compiled with a newer Kotlin version than what Expo uses. The build.gradle forces a compatible kotlin-stdlib and kotlinx-serialization version across the entire project to avoid metadata conflicts:

rootProject.allprojects {
configurations.configureEach {
resolutionStrategy {
// Your project's Kotlin version (set by expo-build-properties or the root build.gradle); 2.1.20 if none is set.
def kv = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : '2.1.20'
force "org.jetbrains.kotlin:kotlin-stdlib:${kv}"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${kv}"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kv}"
force "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.0"
force "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.0"
}
}
}

5.2 SDK Initialization — BlinkEngagePackage.kt​

Create file: modules/blink-engage/android/src/main/java/expo/modules/blinkengage/BlinkEngagePackage.kt

This file runs automatically when your app starts (before any screen renders). It initializes the BlinkReceipt SDK and configures the Activation SDK with reward settings from your app.config.js. The Expo module uses an ApplicationLifecycleListener that hooks into the app startup automatically. BlinkReceiptSdk.initialize() internally configures ActivationClient with deviceId, clientUserId, and appBundleId via ActivationBridge. Host-specific options (rewards, test mode) are then read from AndroidManifest.xml meta-data, which the config plugin writes at prebuild time from app.config.js:

package expo.modules.blinkengage

import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.RewardCurrency
import com.actualplatform.activation.RewardCurrencyCodePosition
import com.actualplatform.activation.RewardCurrencyImageLocation
import com.actualplatform.activation.RewardCurrencyLabelStyle
import com.actualplatform.activation.RewardCurrencyMessagingTextStyle
import com.actualplatform.activation.RewardCurrencyRounding
import com.actualplatform.activation.RewardPoint
import com.actualplatform.activation.ScanReward
import com.actualplatform.activation.networking.TestOptions
import com.microblink.BlinkReceiptSdk
import com.microblink.core.InitializeCallback
import expo.modules.core.interfaces.ApplicationLifecycleListener
import expo.modules.core.interfaces.Package

private const val TAG = "BlinkEngage"

class BlinkEngagePackage : Package {
override fun createApplicationLifecycleListeners(
context: Context
): List<ApplicationLifecycleListener> {
return listOf(BlinkEngageLifecycleListener())
}
}

class BlinkEngageLifecycleListener : ApplicationLifecycleListener {

override fun onCreate(application: Application) {
BlinkReceiptSdk.initialize(application, object : InitializeCallback {
override fun onComplete() {
configureActivationClient(application)
}
override fun onException(throwable: Throwable) {
Log.e(TAG, "BlinkReceiptSdk initialization failed", throwable)
}
})
}

private fun configureActivationClient(application: Application) {
val meta = application.packageManager
.getApplicationInfo(application.packageName, PackageManager.GET_META_DATA)
.metaData

val currencyName = meta?.getString("com.blinkengage.REWARD_CURRENCY_NAME") ?: "points"
val currencyCode = meta?.getString("com.blinkengage.REWARD_CURRENCY_CODE")
val rewardCurrencyPerDollarProp = meta?.getInt("com.blinkengage.REWARD_CURRENCY_PER_DOLLAR", 100) ?: 100
val payoutPct = meta?.getFloat("com.blinkengage.USER_PAYOUT_PERCENTAGE", 100f) ?: 100f
val baseReward = meta?.getInt("com.blinkengage.BASE_REWARD", 10) ?: 10

val codePosition = when (meta?.getString("com.blinkengage.REWARD_CURRENCY_CODE_POSITION")) {
"Trailing" -> RewardCurrencyCodePosition.Trailing
else -> RewardCurrencyCodePosition.Leading
}
val labelStyle = when (meta?.getString("com.blinkengage.REWARD_LABEL_STYLE")) {
"CurrencyName" -> RewardCurrencyLabelStyle.CurrencyName
"CurrencyCode" -> RewardCurrencyLabelStyle.CurrencyCode
else -> RewardCurrencyLabelStyle.CurrencyImage
}
val messagingStyle = when (meta?.getString("com.blinkengage.REWARD_MESSAGING_STYLE")) {
"CurrencyCode" -> RewardCurrencyMessagingTextStyle.CurrencyCode
"NoAmount" -> RewardCurrencyMessagingTextStyle.NoAmount
else -> RewardCurrencyMessagingTextStyle.CurrencyName
}
val rounding = when (meta?.getString("com.blinkengage.REWARD_ROUNDING")) {
"Decimal" -> RewardCurrencyRounding.Decimal
else -> RewardCurrencyRounding.Whole
}
// Parse REWARD_IMAGE_LOCATIONS: "OfferWallItem,ReceiptBoost" -> Set; empty/absent -> ALL.
val imageLocationsStr = meta?.getString("com.blinkengage.REWARD_IMAGE_LOCATIONS") ?: ""
val imageLocations = imageLocationsStr.split(",")
.filter { it.isNotBlank() }
.mapNotNull { name ->
when (name.trim()) {
"OfferWallItem" -> RewardCurrencyImageLocation.OfferWallItem
"ReceiptTotalReward" -> RewardCurrencyImageLocation.ReceiptTotalReward
"ReceiptBoost" -> RewardCurrencyImageLocation.ReceiptBoost
"ReceiptTask" -> RewardCurrencyImageLocation.ReceiptTask
else -> null
}
}
.toSet()
.ifEmpty { RewardCurrencyImageLocation.ALL }

// Parse TEST_OPTIONS: "Test" -> setOf(TestOptions.Test)
val testOptionsStr = meta?.getString("com.blinkengage.TEST_OPTIONS") ?: ""
val parsedTestOptions = testOptionsStr.split(",")
.filter { it.isNotBlank() }
.mapNotNull { name ->
when (name.trim()) {
"Test" -> TestOptions.Test
else -> null
}
}
.toSet()

ActivationClient.instance.apply {
testOptions = parsedTestOptions
scanReward = ScanReward(reward = RewardPoint(baseReward.toDouble()))
rewardCurrency = RewardCurrency.default(
currencyName = currencyName,
currencyCode = currencyCode,
currencyCodePosition = codePosition,
currencyPerDollar = rewardCurrencyPerDollarProp.toDouble(),
// 0–100 meta-data prop → SDK's 0–1 fraction (clamped 0.4…1.0).
userPayoutPercentage = payoutPct.toDouble() / 100.0,
rewardCurrencyLabelStyle = labelStyle,
rewardCurrencyMessagingTextStyle = messagingStyle,
rewardRounding = rounding,
currencyImageLocations = imageLocations,
)
}
}
}

SDK debouncing: The SDK debounces property changes. Rapid sequential sets (e.g., the receipts SDK setting deviceId followed by the host app setting hashedEmail) coalesce into a single registration call. No need to batch property assignments.

How config flows from JS to native:

app.config.js (rewardCurrencyName, userPayoutPercentage, baseReward, android.testOptions, …)
│
▼ (prebuild — config plugin writes <meta-data> into AndroidManifest.xml)
│
AndroidManifest.xml
│ com.blinkengage.REWARD_CURRENCY_NAME = "points"
│ com.blinkengage.REWARD_CURRENCY_CODE = "$"
│ com.blinkengage.REWARD_CURRENCY_CODE_POSITION = "Leading"
│ com.blinkengage.REWARD_CURRENCY_PER_DOLLAR = "100"
│ com.blinkengage.USER_PAYOUT_PERCENTAGE = "100"
│ com.blinkengage.REWARD_LABEL_STYLE = "CurrencyImage"
│ com.blinkengage.REWARD_MESSAGING_STYLE = "CurrencyName"
│ com.blinkengage.REWARD_ROUNDING = "Whole"
│ com.blinkengage.REWARD_IMAGE_LOCATIONS = "OfferWallItem,ReceiptBoost"
│ com.blinkengage.BASE_REWARD = "10"
│ com.blinkengage.TEST_OPTIONS = "Test"
│
▼ (runtime — PackageManager.GET_META_DATA)
│
BlinkEngagePackage.kt → RewardCurrency.default(...) → ActivationClient.instance

Configuration properties:

All reward fields below are assembled into a single RewardCurrency.default(...) object (assigned to ActivationClient.instance.rewardCurrency), except scanReward and testOptions.

Field (RewardCurrency.default(...))Meta-data keyDefaultDescription
scanRewardcom.blinkengage.BASE_REWARDScanReward(RewardPoint(10.0))Base reward amount emitted on every successful scan as Rewards.ScanFinished. Wrapped in RewardPoint.
currencyNamecom.blinkengage.REWARD_CURRENCY_NAME"points"Display name for the reward currency. Trimmed; blank → "points"; truncated to 8 characters.
currencyCodecom.blinkengage.REWARD_CURRENCY_CODEnullOptional symbol/code (e.g. "$", "€"). Absent → null, which disables code-based label/messaging styles.
currencyCodePositioncom.blinkengage.REWARD_CURRENCY_CODE_POSITIONLeadingLeading ($1,234) or Trailing (1,234€). Only relevant when a code is shown.
currencyPerDollarcom.blinkengage.REWARD_CURRENCY_PER_DOLLAR100.0How many currency units one dollar is worth (e.g. 100.0 means 100 points = $1).
userPayoutPercentagecom.blinkengage.USER_PAYOUT_PERCENTAGE100 (prop)Fraction of gross value paid to the user. The meta-data carries a 0–100 percentage; BlinkEngagePackage.kt divides by 100 to the SDK's 0–1 fraction, which RewardCurrency clamps to 0.4…1.0.
rewardCurrencyLabelStylecom.blinkengage.REWARD_LABEL_STYLECurrencyImageHow reward labels read: CurrencyImage (1,234 + icon), CurrencyName (1,234 gems), or CurrencyCode ($1,234).
rewardCurrencyMessagingTextStylecom.blinkengage.REWARD_MESSAGING_STYLECurrencyNameScan-toast copy style: CurrencyName, CurrencyCode, or NoAmount.
rewardRoundingcom.blinkengage.REWARD_ROUNDINGWholeWhole (ceils to next integer) or Decimal (two decimal places).
currencyImageLocationscom.blinkengage.REWARD_IMAGE_LOCATIONSALL (all surfaces)Comma-separated subset of OfferWallItem, ReceiptTotalReward, ReceiptBoost, ReceiptTask. Absent/empty → all four surfaces.
testOptionscom.blinkengage.TEST_OPTIONSemptySet()Parsed from a comma-separated string (currently only "Test" is recognized). TestOptions.Test returns test promotions and registers the device as a Google Ad Manager test device. Development only — omit in production.

For full field semantics (normalization, payout math, RewardPoint), see the Android Reward Currency reference — the native API is identical across Android Native and React Native.

Platform note — currency configuration: On Android these props are assembled into a single RewardCurrency.default(...) object (the same API documented for Android Native). Reward math is driven by baseReward (scan reward), currencyPerDollar (display conversion, e.g. 100.0 means 100 points = $1), and userPayoutPercentage (the user's share of gross value). Both platforms now consume userPayoutPercentage as a 0–1 fraction internally — the shared app.config.js prop stays a human-friendly 0–100 percentage, and each platform divides by 100 before passing it to the SDK.

How the amount is rendered — pick one style, not a sequence. rewardLabelStyle selects a single presentation for the reward amount; the three values are alternatives, not a fallback chain:

StyleRenders asUses
CurrencyImage (default)1,234 + coin iconcurrencyImageLocations controls which surfaces show the icon
CurrencyName1,234 pointscurrencyName
CurrencyCode$1,234 / 1,234€currencyCode + currencyCodePosition

currencyCode and currencyCodePosition are only drawn under the CurrencyCode style — and CurrencyCode falls back to CurrencyName when no currencyCode is set (you can't render a code that doesn't exist). rewardMessagingStyle styles the post-scan toast copy the same way (CurrencyName / CurrencyCode / NoAmount), independently of the label style. See the Android Reward Currency reference for the full rendering tables.

5.3 Module Definition — BlinkEngageModule.kt​

Create file: modules/blink-engage/android/src/main/java/expo/modules/blinkengage/BlinkEngageModule.kt

This is the main bridge between JavaScript and native code. It defines the functions you can call from React Native (setUser, clearUser, etc.), the events it can emit to JS (onReward), and registers the native offer wall view. The module uses Expo's Kotlin Module API:

package expo.modules.blinkengage

import com.actualplatform.activation.ActivationClient
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class BlinkEngageModule : Module() {

override fun definition() = ModuleDefinition {
Name("BlinkEngage")

Events("onReward")

ActivationFlowState.onRewardEarned = { payload ->
this@BlinkEngageModule.sendEvent("onReward", payload)
}

View(OfferWallView::class) {
}

Function("setUser") { params: Map<String, String> ->
val client = ActivationClient.instance
params["emailHash"]?.let { client.hashedEmail = it }
params["phoneHash"]?.let { client.hashedPhone = it }
params["clientUserId"]?.let { client.clientUserId = it }
}

Function("clearUser") {
ActivationClient.instance.apply {
hashedEmail = null
hashedPhone = null
clientUserId = null
}
}

// On Android, the offer wall is an embedded view (OfferWallView).
// showOfferWall / dismissOfferWall are no-ops — present on iOS only.
Function("showOfferWall") { true }
AsyncFunction("dismissOfferWall") { promise: Promise -> promise.resolve(true) }
}
}

Reward forwarding: ActivationFlowState is a shared singleton that bridges OfferWallView (which collects rewards from the SDK's ActivationClient.instance.rewards SharedFlow) to the module (which emits JS events via sendEvent). This only works while the OfferWallView is mounted — see Section 8 for details.

View events: The native view does not emit any events to JS. Close/back navigation is handled by the host app's UI (e.g. a header component), not by the SDK. Rewards are forwarded through the module-level onReward event (see ActivationFlowState above), not through view events.

Other functions exposed by the module​

The snippet above covers the minimum integration path (user identity + offer wall view). The real BlinkEngageModule.kt in this project exposes a few additional functions you can wire up as needed — all of them are thin bridges around the same ActivationClient.instance:

FunctionPurpose
startReceiptScan()Standalone, non-monetized receipt scan. Launches the BlinkReceipt camera via CameraRecognizerContract and resolves a promise with serialized scan results — no ads, no Activation flow.
testBridgeConnection(msg)Debug ping — echoes "Native received: <msg>". Sanity check that the JS ↔ Kotlin bridge is wired correctly.

These are optional — you can omit any of them if you only need the offer wall flow.

5.4 Embedded Offer Wall — OfferWallView.kt​

Create file: modules/blink-engage/android/src/main/java/expo/modules/blinkengage/OfferWallView.kt

This is the most complex file in the integration. It embeds the SDK's offer wall (which uses Jetpack Compose) directly into React Native's view hierarchy — no separate Activity or screen transition needed.

Why this is non-trivial: React Native uses its own layout system (Yoga/Flexbox). Android's native View system uses a different measure/layout protocol. Jetpack Compose adds yet another layer. To bridge all three, we need a FrameLayout wrapper, manual measure/layout overrides, and a polling mechanism to ensure the Compose content renders at the correct size. Without this boilerplate, the offer wall would either not appear or render at 0×0 pixels.

Here is the complete file — copy it as-is:

package expo.modules.blinkengage

import android.content.Context
import android.util.Log
import android.widget.FrameLayout
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView

import com.microblink.ScanOptions
import com.microblink.camera.ui.CameraCharacteristics
import com.microblink.camera.ui.CameraRecognizerContract
import com.microblink.camera.ui.CameraRecognizerOptions
import com.microblink.camera.ui.CameraRecognizerResults

import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.OffersWall
import com.actualplatform.activation.Rewards

/**
* Android equivalent of iOS OfferWallView.
*
* Embeds the Activation SDK [OffersWall] composable (browse-only offer wall with host-owned scan).
* Rewards are collected from [ActivationClient.instance.rewards] and forwarded to JS via
* [ActivationFlowState].
*/
class OfferWallView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {

companion object {
private const val TAG = "OfferWallView"
}

private var isEmbedded = false
private var embedRequested = false

private var offerWallComposeView: ComposeView? = null

private val layoutPoller = object : Runnable {
private var attempts = 0
override fun run() {
forceChildLayout()
if (++attempts < 10 && isAttachedToWindow) {
postDelayed(this, 50)
}
}
fun reset() { attempts = 0 }
}

init {
addView(FrameLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
})
}

// ======================================================================
// View lifecycle
// ======================================================================

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
for (i in 0 until childCount) {
getChildAt(i).measure(widthMeasureSpec, heightMeasureSpec)
}
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val w = r - l
val h = b - t
for (i in 0 until childCount) {
getChildAt(i).layout(0, 0, w, h)
}

if (!embedRequested && !isEmbedded && w > 0 && h > 0) {
Log.d(TAG, "onLayout: first valid size ${w}x${h}, requesting embed")
embedRequested = true
post { embedOfferWall() }
}
}

override fun onDetachedFromWindow() {
Log.d(TAG, "onDetachedFromWindow")
super.onDetachedFromWindow()
removeCallbacks(layoutPoller)
teardown()
}

// ======================================================================
// Embed — attach ComposeView to the container
// ======================================================================

private fun embedOfferWall() {
if (isEmbedded) return

this@OfferWallView.post {
if (!isAttachedToWindow || isEmbedded) return@post

try {
val container = getChildAt(0) as FrameLayout

val composeView = ComposeView(context).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setOfferWallContent()
}
offerWallComposeView = composeView

container.addView(composeView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
))
isEmbedded = true

forceChildLayout()

layoutPoller.reset()
post(layoutPoller)
} catch (e: Throwable) {
Log.e(TAG, "Failed to embed offer wall", e)
isEmbedded = false
embedRequested = false
offerWallComposeView = null
}
}
}

// ======================================================================
// Compose content — SDK callbacks and camera integration
// ======================================================================

private fun ComposeView.setOfferWallContent() = setContent {
val cameraOptions = remember {
CameraRecognizerOptions.Builder()
// BlinkReceipt 2.2.0: `.activation(true)` moved from
// CameraRecognizerOptions.Builder to ScanOptions.Builder. It marks the
// scan as monetized so the camera funnels it through the Activation
// reward pipeline.
.options(ScanOptions.newBuilder().activation(true).build())
.characteristics(
CameraCharacteristics.Builder()
.cameraPermission(true)
.build()
)
.build()
}

val launcher = rememberLauncherForActivityResult(
contract = CameraRecognizerContract(),
) { result ->
when (result) {
is CameraRecognizerResults.Success -> {
Log.d(TAG, "Camera scan success")
}
is CameraRecognizerResults.Exception -> {
Log.e(TAG, "Camera scan error", result.exception)
}
else -> {
Log.d(TAG, "Camera scan cancelled")
}
}
}

LaunchedEffect(Unit) {
ActivationClient.instance.rewards.collect { reward -> dispatchRewards(reward) }
}

Log.d(TAG, "Composing OffersWall")
OffersWall(
onScanReceipt = {
Log.d(TAG, "OffersWall onScanReceipt: launching camera")
// Launch and return. Do NOT await the result — see the warning below.
launcher.launch(cameraOptions)
},
onDismiss = null,
)
}

private fun dispatchRewards(reward: Rewards) {
val rewardType = when (reward) {
is Rewards.ScanFinished -> "ScanFinished"
is Rewards.Promotion -> "Promo"
is Rewards.Boost -> "Boost"
// 1.1.2: hard currency mode banks a boost credit, then settles it on a later scan.
is Rewards.BoostCreditEarned -> "BoostCreditEarned"
is Rewards.BoostCreditApplied -> "BoostCreditApplied"
}
// 1.1.0: amount is a RewardPoint (value class) — read the raw number via `.value`.
// Every reward also carries a `blinkReceiptId` for correlation.
val amount = reward.amount.value
Log.d(TAG, "onRewardEarned: type=$rewardType, amount=$amount, id=${reward.blinkReceiptId}")

ActivationFlowState.onRewardEarned?.invoke(
mapOf(
"context" to rewardType,
"amount" to amount,
"blinkReceiptId" to reward.blinkReceiptId,
),
)
}

// ======================================================================
// Helpers
// ======================================================================

private fun forceChildLayout() {
val w = width
val h = height
if (w > 0 && h > 0) {
val ws = MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY)
val hs = MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
for (i in 0 until childCount) {
val child = getChildAt(i)
child.measure(ws, hs)
child.layout(0, 0, w, h)
}
}
}

private fun teardown() {
if (!isEmbedded) return
Log.d(TAG, "teardown: removing ComposeView")

val container = getChildAt(0) as? FrameLayout
container?.removeAllViews()
isEmbedded = false
embedRequested = false
offerWallComposeView = null
}

}

/**
* Shared state for communication between the module and the active offer wall view.
*/
object ActivationFlowState {
/** Set by the module — emits the "onReward" JS event via sendEvent(). */
var onRewardEarned: ((Map<String, Any>) -> Unit)? = null
}

Understanding the view embedding pattern​

The code above has three layers, each solving a specific problem:

LayerWhatWhy
ExpoView (outer)Base class that Expo uses to host native views in RNMakes this view discoverable by requireNativeViewManager('BlinkEngage') on the JS side
FrameLayout (middle)Simple Android container added in init {}Acts as a stable parent for the ComposeView. We can't add the ComposeView directly to ExpoView because it needs a standard Android container
ComposeView (inner)Jetpack Compose host, created in embedOfferWall()Bridges Android's View system to Compose, where the OffersWall SDK composable renders

Why onMeasure / onLayout overrides? React Native uses Yoga (its own layout engine) to compute sizes, but doesn't trigger Android's native measure/layout cycle on child views. Without these overrides, the FrameLayout and ComposeView would have zero size and nothing would render.

Why layoutPoller? Even after the initial layout, the ComposeView sometimes needs a few extra layout passes (50ms apart, up to 10 attempts) before it fully renders. This is a workaround for a timing issue between React Native's layout cycle and Compose's composition.

Why embedOfferWall() is deferred? The ComposeView is only created after the first onLayout with a non-zero size. This prevents the SDK from trying to render into a 0×0 container, which would cause visual glitches or crashes.

Key implementation details​

  • .activation(true) — This is the key flag. It tells the camera activity to handle the entire post-scan flow internally: scan the receipt, show ads, display the receipt summary, and emit rewards. Your code just launches the camera — the SDK does the rest. No scan result mapping or forwarding is needed on the host side.
  • onScanReceipt — A suspend function called when the user taps "Scan Receipt" in the offer wall. All it needs to do is launcher.launch(cameraOptions). The SDK handles everything else.
  • onDismiss = null — The SDK's built-in dismiss handling is disabled. Close/back navigation is handled by the host app's RN header component, not by the SDK.
  • ActivationFlowState — A shared singleton that connects this view to BlinkEngageModule. The module sets the onRewardEarned callback (which calls sendEvent to JS); this view invokes it when the SDK emits rewards.

How the scan + rewards flow works​

  1. User taps "Scan Receipt" in the OffersWall composable
  2. onScanReceipt fires → launches the camera activity with .activation(true)
  3. The camera activity handles everything internally:
    • Scans the receipt
    • Converts scan results to the Activation SDK format
    • Shows the ads loading screen (fetches ad from the backend)
    • Processes the receipt (validates, matches promotions)
    • Shows the receipt summary screen (matched offers, boosts)
    • Emits Rewards.ScanFinished, Rewards.Promotion, and Rewards.Boost via its internal RewardsManager — or, for apps with hard currency mode enabled (Activation SDK 1.1.2+), Rewards.BoostCreditEarned in place of Rewards.Boost, settled later by a standalone Rewards.BoostCreditApplied
  4. Camera activity finishes → the launcher callback runs (for optional logging/error handling only)
  5. The LaunchedEffect in OfferWallView collects the emitted rewards from ActivationClient.instance.rewards and forwards them to JS via ActivationFlowState

Why no scan result mapping? With .activation(true), the camera activity handles scan result conversion, ad display, and receipt processing internally. The host app doesn't need to touch scan results at all — it just launches the camera and listens for rewards.

Runtime safety: If the Activation SDK is not present at runtime, the .activation(true) flag is silently ignored and the camera returns results normally.

Known issue in Activation 1.2.0

Do not hold the scan session open across the camera launch. onScanReceipt is a suspend function, so awaiting the launcher result looks like the intended contract — but it keeps a session open that the camera then rides along on instead of owning, and an unowned session is never registered with Actual's backend. A device-tier review request (a missing date or total, an unreadable photo) is filed before the receipt reaches processing, so it becomes the first thing to reference that session and fails with "Scan session not found". Nothing throws and nothing reaches an error callback; the shopper simply cannot submit the review. Launch the camera and return, as in setOfferWallContent() above. Addressed in a later release — see Scan sessions.


6. JavaScript Bridge (Expo Modules API)​

Now that the native Kotlin files are in place, you need 3 TypeScript files that connect your React Native code to the native module. These are standard Expo Modules API boilerplate — you'll recognize the patterns from any Expo module.

6.1 Native Module Loader​

Create file: modules/blink-engage/src/BlinkEngageModule.ts

import { requireNativeModule } from 'expo-modules-core';

export default requireNativeModule('BlinkEngage');

This one-liner loads the native BlinkEngageModule.kt you created in Section 5.3. The string 'BlinkEngage' must match the Name("BlinkEngage") in your Kotlin module definition.

6.2 Native View​

Create file: modules/blink-engage/src/OfferWallView.tsx

import { requireNativeViewManager } from 'expo-modules-core';

const NativeOfferWallView = requireNativeViewManager('BlinkEngage');

export function OfferWallView({ style }: OfferWallViewProps) {
return (
<NativeOfferWallView
style={style}
/>
);
}

This wraps the native OfferWallView.kt (from Section 5.4) as a React component. The 'BlinkEngage' string must match the module name that registered the View(OfferWallView::class).

6.3 Public API​

Create file: modules/blink-engage/src/index.ts

This is the file your app imports from. It wraps the native module with a clean TypeScript API. SHA-256 hashing is done on the JS side using expo-crypto, so plain-text email/phone never crosses the bridge. Platform-specific shims (e.g. iOS-only showOfferWall options) are handled here, not in the host app — consumers write platform-free code.

import * as Crypto from 'expo-crypto';
import { type EventSubscription } from 'expo-modules-core';
import { Platform } from 'react-native';
import BlinkEngageModule from './BlinkEngageModule';

async function hashSHA256(value: string): Promise<string> {
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
value,
);
}

// --- User identity ---

export async function setUser(params: BlinkEngageUserParams): Promise<void> {
const nativeParams: Record<string, string> = {};
if (params.email) {
nativeParams.emailHash = await hashSHA256(params.email.toLowerCase());
}
if (params.phone) {
nativeParams.phoneHash = await hashSHA256(params.phone);
}
if (params.clientUserId) {
nativeParams.clientUserId = params.clientUserId;
}
BlinkEngageModule.setUser(nativeParams);
}

export function clearUser(): void {
BlinkEngageModule.clearUser();
}

// --- Offer wall ---
// On Android the offer wall is an embedded <OfferWallView />;
// showOfferWall / dismissOfferWall are iOS-primary and act as no-ops on Android.

export function showOfferWall(options?: OfferWallOptions): Promise<boolean> {
if (Platform.OS === 'ios') {
return BlinkEngageModule.showOfferWall(options);
}
return BlinkEngageModule.showOfferWall();
}

export function dismissOfferWall(): Promise<boolean> {
return BlinkEngageModule.dismissOfferWall();
}

// --- Standalone scans (optional) ---

export function startReceiptScan(
options?: ReceiptScanOptions,
): Promise<ReceiptScanResult> {
if (Platform.OS === 'android') {
return BlinkEngageModule.startReceiptScan();
}
return BlinkEngageModule.startReceiptScan(options);
}

// --- Rewards ---

export function addRewardListener(
listener: (event: RewardEvent) => void,
): EventSubscription {
return BlinkEngageModule.addListener('onReward', listener);
}

// --- Debug ---

export function testBridgeConnection(message: string): string {
return BlinkEngageModule.testBridgeConnection(message);
}

The OfferWallView React component is also re-exported from src/index.ts so host code can do import { OfferWallView } from 'blink-engage' without reaching into a subpath. See Section 7 for usage.


7. Usage in React Native​

Showing the Offer Wall​

On Android, the offer wall is rendered as an embedded native ComposeView that can be placed anywhere in your React Native layout. The recommended approach is to render it as a dedicated screen's content:

import { View } from 'react-native';
import { OfferWallView } from 'blink-engage';
import { OfferWallHeader } from '@/components/@offerWall/OfferWallHeader';
import { useRouter } from 'expo-router';

export default function OfferWallScreen() {
const router = useRouter();

return (
<View style={{ flex: 1 }}>
<OfferWallHeader title="Offers" onClose={() => router.back()} />
<OfferWallView style={{ flex: 1 }} />
</View>
);
}

The OfferWallView is a composable native view — it can be embedded in any layout, not just a full screen. However, it needs a non-zero size (style={{ flex: 1 }} or explicit dimensions) and should stay mounted during the entire scan flow so reward events are not lost (see Section 8).

Close/back navigation is handled by the host app (e.g. OfferWallHeader above), not by the SDK — the native OffersWall is rendered with onDismiss = null.

Setting User Identity​

Required: At least one of email or phone must be set before displaying the offer wall. The offer wall will not load promotions without a registered user identity.

import * as BlinkEngage from 'blink-engage';

// After user logs in — must be called before showing the offer wall
await BlinkEngage.setUser({
email: 'user@example.com',
phone: '+15551234567',
clientUserId: 'user-123',
});

// On logout
BlinkEngage.clearUser();
awaiting setUser() does not mean registration completed

setUser() resolves once hashedEmail/hashedPhone are set on the native ActivationClient — not once registration with the backend has finished. The native SDK now exposes this distinction as ActivationClient.registrationState (see Registration State in the 1.1.2 native docs), but that StateFlow is not currently bridged to JS by BlinkEngageModule. If your offer wall is empty right after login, the debounce-and-network registration call may simply not have finished yet — bridging registrationState (e.g. as an onRegistrationStateChanged event, mirroring the onReward pattern in Section 5.3) is the fix if this becomes an issue for your app.

Listening for Rewards​

import * as BlinkEngage from 'blink-engage';

useEffect(() => {
const subscription = BlinkEngage.addRewardListener((event) => {
console.log(`Earned ${event.amount} (${event.context})`);
setTotalRewards((prev) => prev + event.amount);
});

return () => subscription.remove();
}, []);

8. Handling Rewards (On-Device)​

Important: The on-device SDK rewards flow is not the only reward channel. Promotional rewards, boost rewards, UGC rewards, and missed earnings rewards are calculated by Actual's backend and delivered to your server via webhooks. Both channels are required:

  • SDK rewards flow — for real-time in-app reward display
  • Webhooks — for reliable server-side tracking, auditability, and crediting users

See Section 9 for webhook details.

Rewards are emitted as native events through the Expo Modules event system. Each event contains:

FieldTypeDescription
amountnumberPoints earned for this reward (read from the SDK's RewardPoint.value)
contextstringThe reward type: "ScanFinished", "Promo", "Boost", "BoostCreditEarned", or "BoostCreditApplied" on Android ("BarcodeCollection" is iOS only)
blinkReceiptIdstringReceipt ID that triggered the reward. As of Activation SDK 1.1.0 every reward carries it — use it to correlate the event with its receipt (analytics / dedup).
scanResultsRewardScanResults?Scan results data (only present for ScanFinished context)

Note: context values are mapped from the SDK's Rewards sealed class in OfferWallView.kt — e.g. Rewards.Promotion → "Promo". Reward amounts are typed as RewardPoint on the SDK side (a value class over Double); the module forwards reward.amount.value.

Added in 1.1.2

Rewards.BoostCreditEarned and Rewards.BoostCreditApplied are new subtypes of the native SDK's sealed Rewards type, added for hard currency mode (see the Activation Android SDK 1.1.2 release notes). If you copied dispatchRewards from Section 5.4 into your own bridge module, its when (reward) is exhaustive over Rewards and will not compile once you upgrade to activationVersion: '1.1.2' until you add branches for both — this doc's sample already does. Hard currency mode is enabled per app by the Actual team; if it's never enabled for your app, Rewards.BoostCreditEarned and Rewards.BoostCreditApplied simply never fire, but the branches must still exist for the when to compile.

Reward types​

TypeTriggerSource
ScanFinishedEvery successful receipt scanActivationClient.scanReward value (set in BlinkEngagePackage.kt)
Promo (Promotion)Scanned receipt matches a qualified promotionBackend calculates based on userPayoutPercentage and matched offer values
BoostUser watches a rewarded ad or redeems a CPA offer on the receipt summary screenSDK emits when ad completes or CPA is redeemed
BoostCreditEarnedUser watches a rewarded ad, under hard currency modeSDK banks the credit instead of paying it out immediately
BoostCreditAppliedCredit banked earlier lands on this receiptSDK settles a previously banked BoostCreditEarned against this scan

BarcodeCollection is currently iOS only. It will be supported on Android once User Generated Content (UGC) is implemented in a future Activation SDK release. Until then, the Android SDK has no BarcodeCollection reward, so OfferWallView.kt never maps one and Android never emits that context to JS. On Android, barcode-collection rewards reach your backend only through the barcode_collection webhook, if your account has it enabled.

Reward flow on Android​

  1. User scans receipt via the offer wall → camera activity runs with .activation(true)
  2. Inside the camera activity, ScanResultsFlow processes the receipt:
    • QualifiedPromotionsUseCase checks ActivationClient.scanReward → emits Rewards.ScanFinished if > 0
    • Validates receipt against backend promotions → emits Rewards.Promotion if matched earnings > 0
  3. Receipt summary shows boost offers → emits Rewards.Boost when claimed
  4. OfferWallView's LaunchedEffect collects from ActivationClient.instance.rewards and forwards to JS via ActivationFlowState.onRewardEarned → BlinkEngageModule.sendEvent("onReward", ...)

Session lifecycle​

Reward state is scoped to a scan session. When a new scan session starts, the SDK resets its internal session. Clear your local reward accumulators (e.g. setTotalRewards(0)) when starting a new scan flow to avoid stale totals from a previous session.

Important: rewards only flow while OfferWallView is mounted​

The reward collection runs in a LaunchedEffect inside OfferWallView. If the view is unmounted (e.g. user navigated away), rewards emitted by the SDK will be lost because ActivationClient.instance.rewards is a SharedFlow with limited buffer. Make sure the OfferWallView stays mounted during the entire scan flow.


9. Server-Side Rewards (Webhooks) (Backend)​

Actual's backend sends webhook POST requests to your server when receipts are processed and rewards are calculated. This is the authoritative source for all backend-calculated rewards (promotions, boosts, UGC, missed earnings).

Setup​

Provide the following to your Actual Account Management team:

  • Which API key to use (share securely or only share the last 3 characters)
  • Your app's bundle identifier (e.g. com.yourcompany.app)
  • Your webhook endpoint URL (e.g. https://api.yourcompany.com/webhooks/actual)
  • Which event types to subscribe to: ReceiptProcessed, RewardUpdate, or both
  • Any custom HTTP headers you want included in webhook requests (optional)

Your endpoint must be HTTPS with a valid SSL certificate.

Event Types​

EventWhen It FiresWhat It Contains
ReceiptProcessedAfter a receipt is validated and processedReceipt data, promotion match results, fraud status, blink_receipt_id
RewardUpdateWhen a reward is calculated by the backendReward type (promo, boost, ugc), amount, blink_receipt_id, reward_id

Key Design Considerations​

  • Multiple webhooks per receipt: A single receipt can trigger separate webhooks for promo, boost, UGC, and missed earnings. Handle multiple events per blink_receipt_id.
  • Idempotency: The same webhook can be delivered more than once during retries. Use reward_id or blink_receipt_id + reward_type to deduplicate.
  • Store blink_receipt_id: This is the primary key that links ReceiptProcessed, RewardUpdate, and on-device scan context.
  • No "all done" signal: There is no event indicating that all webhooks for a given receipt have been delivered. Missed earnings can add rewards hours or days later. Design for open-ended reward accrual per receipt.
  • Retry behavior: Webhooks retry 3 times over ~65 seconds, then go to a dead letter queue. Build monitoring for missing expected webhooks.

10. Error Flows​

A receipt the SDK can't accept does not end the flow or come back as an error. Each problem raises its own screen that explains what happened and offers to send the receipt to a human reviewer; dismissing it returns the shopper to the offer wall. Checks run in two tiers — on device before the scan is submitted, and on the server after.

ProblemDetectedWhat the shopper seesRetake offered?
Photo can't be readDeviceInvalid-receipt screenYes
Date or total missingDeviceForm asking for what's missing; continues to processing on submitYes
Store name missing aloneDeviceNothing — treated as a complete scan—
Purchase date outside the rewards windowDevice or serverReceipt-too-old screenNo
Submission limit reachedServerFrequency-cap screen naming each limit—
DuplicateServerReceipt summary, duplicate banner, rewards suppressed—
Actionable fraud flagServerReceipt summary, invalid banner, rewards suppressed—

There is nothing to implement in the native module, the config plugin, or JavaScript. Because OfferWallView embeds OffersWall and launches the camera with .activation(true), the camera activity presents these screens itself and handles "Retake photo" by resuming its own capture session — so the receipt keeps the same blinkReceiptId across retakes. The onResumeScan and onScanAccepted parameters added in 1.2.0 apply only to OffersWallFlow, which this integration does not use.

Two things do reach your integration:

  • The scan reward is deferred for any flow that ends at a review request. The backend grants it only if a reviewer accepts, arriving later as a RewardUpdate webhook rather than through addRewardListener() — see Section 9. Don't treat the on-device reward events as final.
  • A correction the shopper types is never merged into the scan. It travels to the reviewer only, so a field that was missing at scan time is still missing on the receipt summary.

See Error Flows for how the checks are ordered and when rewards land, and Error Flows Summary for what the shopper sees in each case.

Keyboard insets — check softwareKeyboardLayoutMode​

The review screens contain text fields, and they inset themselves for the software keyboard from the window's IME insets. Those insets only arrive when the hosting Activity resolves to android:windowSoftInputMode="adjustResize"; otherwise the keyboard covers the form's input and submit button and the shopper cannot complete a review request.

Under CNG you do not hand-edit AndroidManifest.xml — prebuild regenerates it. Expo derives that attribute from android.softwareKeyboardLayoutMode in app.config.js, which defaults to resize:

android: {
softwareKeyboardLayoutMode: 'resize', // → android:windowSoftInputMode="adjustResize"
},

Most projects need no change. The one to watch for is an explicit softwareKeyboardLayoutMode: 'pan', which breaks the review forms. Verify what actually landed after a prebuild:

grep -n "windowSoftInputMode" android/app/src/main/AndroidManifest.xml
Requires Activation 1.2.0

Error Flows ship in Activation 1.2.0. Set android.activationVersion to '1.2.0' or later — see Section 4.2.


11. Missed Earnings (Backend)​

If a receipt scan does not initially match promotions (e.g., due to OCR quality or missing items), the user may be presented with a missed earnings correction flow. This allows users to manually verify or correct scanned items to recover eligible rewards.

Missed earnings rewards are delivered via RewardUpdate webhooks and may arrive hours or days after the original scan. Design your backend to handle open-ended reward accrual per receipt.


12. Debug Mode​

These options are intended for development and testing only. Do not enable them in production builds.

Test Options​

The recommended way is to set them in app.config.js under the plugin's android.testOptions:

{
android: {
// ...
testOptions: ['Test'], // Test = test promotions + Google Ad Manager test ad units
},
}

The plugin writes these to com.blinkengage.TEST_OPTIONS meta-data; BlinkEngagePackage.kt reads and applies them at startup (see §5.2). Remove testOptions — or set it to [] — for production builds.

You can also set them imperatively from native code at any point after init:

import com.actualplatform.activation.networking.TestOptions

ActivationClient.instance.testOptions = setOf(
// Enables test mode: returns test promotions from the backend and
// registers this device as a Google Ad Manager test device, so ad
// units serve test creatives.
TestOptions.Test,
)

Logging​

Enable SDK debug logging by adding the following to your project's local.properties:

logcat.state=enabled

In an Expo (CNG) project, local.properties lives inside the generated native Android project, not at the repo root — i.e. ./<root>/android/local.properties. This file is regenerated by expo prebuild, so if you don't see it, run prebuild first (see Section 14) and re-add the property afterward, or add it via a config plugin so it survives future prebuilds.

This activates verbose logging across the Paper Receipt and Activation SDKs, including network requests, scan session lifecycle, reward events, and receipt validation results.


13. Cleanup & Teardown​

You normally don't need to close anything. Android doesn't tell an app when its process ends: Application.onTerminate() is only called on emulators, and Expo's ApplicationLifecycleListener has no onDestroy — only onCreate and onConfigurationChanged — so there is no callback to hook a teardown into, in either Expo CNG or a bare React Native project. The system releases the SDKs' resources when the process ends.

Close the client explicitly only when you need a clean restart within one process, for example in an end-to-end test that re-initializes the SDKs. Expose it as a module function and call it from JS:

// In BlinkEngageModule.definition()
Function("shutdown") {
BlinkReceiptSdk.terminate()
ActivationClient.instance.close()
}

14. Building & Prebuilding​

Prebuild (Generate Native Projects)​

npx expo prebuild --platform android
# or use the project script:
npm run prebuild:android

This regenerates the android/ directory and runs all config plugins, which will:

  1. Add <meta-data> entries to AndroidManifest.xml:
    • com.microblink.LicenseKey (BlinkReceipt license)
    • com.microblink.ProductIntelligence (if android.prodIntelKey was provided)
    • com.google.android.gms.ads.APPLICATION_ID (GAM App ID)
    • com.blinkengage.REWARD_CURRENCY_NAME, REWARD_CURRENCY_CODE, REWARD_CURRENCY_CODE_POSITION, REWARD_CURRENCY_PER_DOLLAR, USER_PAYOUT_PERCENTAGE, REWARD_LABEL_STYLE, REWARD_MESSAGING_STYLE, REWARD_ROUNDING, REWARD_IMAGE_LOCATIONS, BASE_REWARD
    • com.blinkengage.TEST_OPTIONS (if android.testOptions was provided)
  2. Write modules/blink-engage/android/sdk-versions.properties with the activation_version and blinkreceipt_version the module's build.gradle will resolve.
  3. Copy any themeIcons listed in app.config.js into android/app/src/main/res/drawable/ using the SDK's expected snake_case names (e.g. offer_reward_icon.png, post_scan_success_icon.png).

Development Build​

# Local APK build
npm run build:android:dev:sim

# Or via EAS
npm run build:android:dev:sim:eas

Preview / Production​

npm run build:android:preview
npm run build:android:prod

Verifying the Plugin Ran Correctly​

After prebuild, you can inspect the generated native files:

# Check manifest meta-data
grep "LicenseKey\|APPLICATION_ID\|blinkengage" android/app/src/main/AndroidManifest.xml

# Check SDK versions written by the plugin
cat modules/blink-engage/android/sdk-versions.properties

# Check theme drawables copied into the resources
ls android/app/src/main/res/drawable/ | grep -E "offer|post_scan|missed_earnings|ugc"

15. Configuration Reference​

Plugin Props​

Props are grouped into platform blocks (ios, android) plus a shared block. Only Android and shared props are listed here.

android block

PropTypeRequiredDescription
licenseKeystringYesBlinkReceipt license key (from .env)
prodIntelKeystringNoBlinkReceipt Product Intelligence key (enables PI features when present)
gadAppIdstringYesGoogle AdMob Application ID for Android (from ANDROID_GAD_APP_ID)
activationVersionstringNocom.actualplatform:activation Maven version (default: "1.2.0")
blinkReceiptVersionstringNocom.microblink.blinkreceipt:* Maven version (default: "2.4.0")
engageSdkAutoInitbooleanNoandroidx.startup auto-init of the Activation SDK (default: true)
testOptionsstring[]NoCurrently only ["Test"] is recognized. Development only — omit in production.

Shared block (applies to both platforms)

PropTypeRequiredDescription
rewardCurrencyNamestringNoDisplay name for rewards. Trimmed; blank → "points"; truncated to 8 chars. (default: "points")
currencyCodestringNoOptional symbol/code (e.g. "$", "€"). Omit to disable code-based label/messaging styles. (default: none → null)
currencyCodePositionstringNo'Leading' ($1,234) or 'Trailing' (1,234€). (default: 'Leading')
rewardCurrencyPerDollarnumberNoConversion rate (e.g. 100 means 100 reward units = $1). Consumed on both platforms. (default: 100)
userPayoutPercentagenumberNoPercentage (40–100) of gross value shown to the user. (default: 100) — both platforms divide this by 100 to the SDK's 0–1 fraction, clamped to [0.4, 1.0].
rewardLabelStylestringNo'CurrencyImage', 'CurrencyName', or 'CurrencyCode'. (default: 'CurrencyImage')
rewardMessagingStylestringNoScan-toast copy style: 'CurrencyName', 'CurrencyCode', or 'NoAmount'. (default: 'CurrencyName')
rewardRoundingstringNo'Whole' (ceil to integer) or 'Decimal' (two decimals). (default: 'Whole')
rewardImageLocationsstring[]NoSubset of ['OfferWallItem', 'ReceiptTotalReward', 'ReceiptBoost', 'ReceiptTask'] — surfaces that show the coin icon. (default: all four)
baseRewardnumberNoBase scan reward — ActivationClient.scanReward = ScanReward(RewardPoint(baseReward)) (default: 10)
themeIconsobjectNoPer-icon source paths; copied to Android drawables + iOS xcassets

Environment Variables​

VariableDescription
ANDROID_BLINK_LICENSE_KEYBlinkReceipt license key
ANDROID_PROD_INTEL_KEYBlinkReceipt Product Intelligence key
ANDROID_GAD_APP_IDGoogle AdMob Application ID (Android android.gadAppId)

Android Meta-Data (written by plugin, read by BlinkEngagePackage.kt)​

Meta-data keySource propDefaultMaps to
com.microblink.LicenseKeyandroid.licenseKey—BlinkReceipt license
com.microblink.ProductIntelligenceandroid.prodIntelKey—BlinkReceipt PI features
com.google.android.gms.ads.APPLICATION_IDandroid.gadAppId—Google Mobile Ads AdMob App ID
com.blinkengage.REWARD_CURRENCY_NAMErewardCurrencyName"points"RewardCurrency.currencyName
com.blinkengage.REWARD_CURRENCY_CODEcurrencyCode—RewardCurrency.currencyCode
com.blinkengage.REWARD_CURRENCY_CODE_POSITIONcurrencyCodePositionLeadingRewardCurrency.currencyCodePosition
com.blinkengage.REWARD_CURRENCY_PER_DOLLARrewardCurrencyPerDollar100RewardCurrency.currencyPerDollar
com.blinkengage.USER_PAYOUT_PERCENTAGEuserPayoutPercentage100RewardCurrency.userPayoutPercentage (÷100 → 0–1)
com.blinkengage.REWARD_LABEL_STYLErewardLabelStyleCurrencyImageRewardCurrency.rewardCurrencyLabelStyle
com.blinkengage.REWARD_MESSAGING_STYLErewardMessagingStyleCurrencyNameRewardCurrency.rewardCurrencyMessagingTextStyle
com.blinkengage.REWARD_ROUNDINGrewardRoundingWholeRewardCurrency.rewardRounding
com.blinkengage.REWARD_IMAGE_LOCATIONSrewardImageLocationsALLRewardCurrency.currencyImageLocations (parsed Set)
com.blinkengage.BASE_REWARDbaseReward10ActivationClient.scanReward = ScanReward(RewardPoint(baseReward))
com.blinkengage.TEST_OPTIONSandroid.testOptions—ActivationClient.testOptions (parsed Set<TestOptions>)

Artifacts Generated by the Plugin (at prebuild time)​

PathWritten byPurpose
android/app/src/main/AndroidManifest.xml (meta-data)withBlinkEngageAndroidManifestRuntime config read by BlinkEngagePackage.kt
modules/blink-engage/android/sdk-versions.propertieswithBlinkEngageAndroidVersionsMaven versions consumed by the module's build.gradle
android/app/src/main/res/drawable/*.pngwithBlinkEngageAndroidAssetsTheme icons

JS API Summary​

MethodReturnsDescription
setUser(params)Promise<void>Set user identity (auto-hashes PII)
clearUser()voidClear user identity
showOfferWall(options?)Promise<boolean>No-op on Android (use <OfferWallView />); iOS primary
dismissOfferWall()Promise<boolean>No-op on Android; iOS primary
startReceiptScan(options?)Promise<ReceiptScanResult>Standalone non-monetized receipt scan; returns parsed results
addRewardListener(listener)EventSubscriptionSubscribe to reward events; call .remove() to unsubscribe
testBridgeConnection(msg)stringVerify native bridge is working

Components​

ComponentPropsDescription
OfferWallViewstyle?Embedded offer wall view

16. Integration Checklist​

Use this checklist before shipping to verify nothing was missed:

  • .env contains ANDROID_BLINK_LICENSE_KEY, ANDROID_PROD_INTEL_KEY (if using PI), and ANDROID_GAD_APP_ID
  • app.config.js has the blink-engage/plugin registered with correct nested props (ios, android, shared)
  • npx expo prebuild --platform android runs without errors
  • AndroidManifest.xml contains com.microblink.LicenseKey, com.google.android.gms.ads.APPLICATION_ID, and com.blinkengage.* meta-data
  • modules/blink-engage/android/sdk-versions.properties exists and lists the expected activation_version / blinkreceipt_version
  • Theme icons copied to android/app/src/main/res/drawable/ (if themeIcons is set)
  • Paper Receipt SDK initializes (check logcat for BlinkEngageInit tag)
  • Activation SDK detected on classpath (auto-configured by BlinkReceiptSdk.initialize())
  • At least one user identifier set (hashedEmail or hashedPhone) before showing offer wall
  • Reward currency configured as desired (rewardCurrencyName, userPayoutPercentage, baseReward, and any display-style props) — assembled into RewardCurrency natively
  • OfferWallView renders correctly with style={{ flex: 1 }}
  • android.softwareKeyboardLayoutMode is not set to 'pan', and the generated manifest shows windowSoftInputMode="adjustResize" (see Section 10)
  • onScanReceipt in OfferWallView launches the camera and returns — it does not await the launcher result
  • Error-flow screens reachable: unreadable photo, missing date/total, receipt outside the rewards window
  • A device-tier review (missing date or total) submits successfully — no "Scan session not found"
  • Server verdicts (duplicate, submission limit, fraud) exercised with android.testOptions empty — test mode accepts everything
  • Camera permission granted (either via cameraPermission(true) or manually)
  • Reward events received in JS via addRewardListener()
  • Receipt scan completes and shows ads loading → receipt summary → rewards
  • Boost ads display correctly (use android.testOptions: ['Test'] during development)
  • Webhook endpoint receiving ReceiptProcessed and RewardUpdate events
  • app-ads.txt configured and publicly accessible at your domain root
  • android.testOptions removed (or empty) for production builds
  • ActivationClient.close() called on app termination

17. Troubleshooting​

checkAarMetadata fails​

If the build fails in checkAarMetadata saying a dependency requires a higher compileSdk or a newer Android Gradle Plugin, the SDK artifacts need a newer toolchain than the project uses. The Activation and Paper Receipt artifacts for 1.2.0 / 2.4.x and later are built against compileSdk 37, and pull in an androidx.core that requires a recent Android Gradle Plugin. Raise compileSdkVersion to 37 in the module's build.gradle and in your app, and use the Android Gradle Plugin version listed for your SDK pair in the Android SDK Version Matrix. Your app's targetSdk does not need to change.

Kotlin version / metadata conflicts​

If you see Kotlin metadata version errors, make sure the module's build.gradle includes -Xskip-metadata-version-check and forces a consistent kotlin-stdlib version across all modules via resolutionStrategy.

Compose compiler errors​

If Gradle reports "Plugin with id 'org.jetbrains.kotlin.plugin.compose' not found", the module's buildscript block from Section 5.1 is missing.

Make sure the Kotlin Gradle Plugin version is consistent across the project. If using expo-build-properties, set kotlinVersion explicitly.

Maven artifacts not found​

Make sure the module's build.gradle declares maven { url "https://maven.microblink.com" } in its repositories block. Run ./gradlew app:dependencies to verify resolution.

SDK not initializing​

Make sure that BlinkReceiptSdk is initialized and ActivationClient is configured afterwards. The Activation SDK auto-initializes via androidx.startup — check logcat for initialization logs if something goes wrong.

Offer wall not rendering​

The OfferWallView needs a non-zero size — make sure you pass style={{ flex: 1 }} or explicit dimensions. Check logcat for embedding errors.

No ads or rewards after scanning​

  1. No ads on loading screen, or no promotion rewards: During development, add 'Test' to android.testOptions in app.config.js (writes com.blinkengage.TEST_OPTIONS → applied at startup in BlinkEngagePackage.kt). This registers the device as a Google Ad Manager test device so ad units serve test creatives, and returns test promotions that match any receipt.
  2. No scan rewards: Make sure baseReward in app.config.js is non-zero (it maps to ActivationClient.scanReward = ScanReward(RewardPoint(baseReward))). If 0 or missing, Rewards.ScanFinished is not emitted. Verify the com.blinkengage.BASE_REWARD meta-data is present in AndroidManifest.xml after prebuild.
  3. Activation flow not appearing: The camera only runs the Activation flow when the Activation SDK is on the classpath and the scan results carry a non-empty blinkReceiptId. Check that .activation(true) is set on the ScanOptions passed to the camera, and check logcat for errors from the camera.

Camera permission​

The Paper Receipt camera requires android.permission.CAMERA. In our setup, the CameraCharacteristics.Builder().cameraPermission(true) flag tells the camera UI to request permission itself. If you prefer to handle it manually (e.g. to show a custom rationale), request it before navigating to the offer wall:

import * as ExpoCamera from 'expo-camera';

const { status } = await ExpoCamera.requestCameraPermissionsAsync();
if (status !== 'granted') {
// Handle permission denied
}

Reward events not received in JS​

  • Make sure the OfferWallView is mounted when scanning — rewards are collected in a LaunchedEffect inside the view. If the view is not mounted, no one collects from ActivationClient.instance.rewards and events are lost.
  • Make sure addRewardListener is called before the user interacts with the offer wall.