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:
- Set up environment variables — Add
ANDROID_BLINK_LICENSE_KEY,ANDROID_PROD_INTEL_KEY, andGAD_APP_IDto your.envfile (Section 2) - Configure the plugin — Set your license keys, GAM App ID, reward currency, and SDK versions in
app.config.js(Section 4.2) - 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 yourpackage.jsondependencies (Section 3) - Run prebuild —
npx expo prebuild --platform androidto generate native projects with injected metadata (Section 14) - Set user identity — Call
setUser()after login with at least an email or phone (Section 7) - Render the offer wall — Add
<OfferWallView style={{ flex: 1 }} />to a screen (Section 7) - Listen for rewards — Subscribe with
addRewardListener()to show earned rewards in real time (Section 8) - Set up webhooks — Configure your backend to receive
RewardUpdateevents for authoritative reward tracking (Section 9)
For development, enable test ads and test promotions by setting android.testOptions: ['Ads', '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
- Architecture Overview
- Prerequisites
- Project Structure
- Expo Config Plugin — Automated Native Setup
- Native Module (Android)
- JavaScript Bridge (Expo Modules API)
- Usage in React Native
- Handling Rewards (On-Device)
- Server-Side Rewards (Webhooks) (Backend)
- Receipt Validation
- Missed Earnings (Backend)
- Debug Mode
- Cleanup & Teardown
- Building & Prebuilding
- Configuration Reference
- Integration Checklist
- Troubleshooting
1. Architecture Overview
In a bare React Native project, you manually:
- Edit
build.gradleto add Maven repos and dependencies - Edit
AndroidManifest.xmlto 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 viastartActivityForResult - No manual
MainApplicationedits — SDK initialization happens throughApplicationLifecycleListener - 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
minSdk24+ - Kotlin 2.1.20+ (set via
expo-build-propertiesor rootbuild.gradle) - Jetpack Compose enabled (the module's
build.gradlehandles this) - A valid BlinkReceipt / Activation SDK license 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 keyANDROID_PROD_INTEL_KEY— BlinkReceipt Product Intelligence key (optional, enables PI features)GAD_APP_ID— Google AdMob Application ID
3. Project Structure
Distribution status:
blink-engageis not yet published as a standalone module or npm package. For now, integrators copy the entiremodules/blink-engage/directory from this repository into their own project (undermodules/) and link it locally viapackage.json. Publishing it as an installable npm package is planned future work; once that lands, integrators will be able tonpm install blink-engageinstead 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 byexpo prebuild)"prepare"script builds the config plugin automatically when you runnpm installpeerDependenciesare 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— loadsBlinkEngageModuleas the native module (the JS ↔ native bridge from Section 5.3)packages— loadsBlinkEngagePackageas a lifecycle package (SDK initialization from Section 5.2)plugin— points to the compiled config plugin thatexpo prebuildwill 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:
- Injects
<meta-data>entries intoAndroidManifest.xml(license keys, GAM App ID, reward configuration, test options). - Writes
modules/blink-engage/android/sdk-versions.propertiesso the module'sbuild.gradlecan resolve the right Activation / BlinkReceipt artifact versions. - Copies any
themeIconslisted inapp.config.jsintoandroid/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
addMetaData(
'com.google.android.gms.ads.APPLICATION_ID',
props.gadAppId,
);
// Activation SDK reward configuration (read by BlinkEngagePackage.kt at runtime)
addMetaData(
'com.blinkengage.REWARD_CURRENCY_NAME',
props.rewardCurrencyName || 'points',
);
addMetaData(
'com.blinkengage.REWARD_CURRENCY_PER_DOLLAR',
String(props.rewardCurrencyPerDollar ?? 100),
);
addMetaData(
'com.blinkengage.USER_PAYOUT_PERCENTAGE',
String(props.userPayoutPercentage ?? 100),
);
addMetaData(
'com.blinkengage.BASE_REWARD',
String(props.baseReward ?? 10),
);
// Test options (comma-separated, e.g. "Ads,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 byBlinkReceiptSdk.initialize(). If you need to opt out (unusual), setandroid.engageSdkAutoInit: falseand the plugin will add atools:node="remove"entry that stripsActivationInitializerfrom theInitializationProvider. Recommended default istrue.
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',
debugModeEnabled: false,
},
android: {
licenseKey: process.env.ANDROID_BLINK_LICENSE_KEY || '',
prodIntelKey: process.env.ANDROID_PROD_INTEL_KEY,
activationVersion: '1.0.0', // com.actualplatform:activation
blinkReceiptVersion: '2.1.0', // com.microblink.blinkreceipt:*
engageSdkAutoInit: true,
testOptions: ['Ads', 'Test'], // development only — omit for production
},
// Shared (both platforms)
gadAppId: process.env.GAD_APP_ID || '',
rewardCurrencyName: 'points',
rewardCurrencyPerDollar: 100,
userPayoutPercentage: 100,
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:
| Term | What it means (RN analogy) |
|---|---|
ExpoView | Expo's base class for native views — similar to creating a native UI component that renders in your RN layout |
ComposeView | A 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 |
FrameLayout | A simple container view (like a <View> in RN) that holds one child and fills the available space |
suspend function | Kotlin's equivalent of an async function — it can pause and resume without blocking the thread |
ApplicationLifecycleListener | Hooks into the app's startup — runs code when the app launches, before any screen renders |
rememberLauncherForActivityResult | Launches another screen (like the camera) and receives the result when it finishes — similar to startActivityForResult in bare Android |
LaunchedEffect | Runs a side effect when a Compose UI first appears — similar to useEffect(() => {}, []) in React |
object | A Kotlin singleton — one shared instance, like a module-level constant in JS |
sealed class | A 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.
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.0.0')
def blinkReceiptVer = sdkVersions.getProperty('blinkreceipt_version', '2.1.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.0.0
blinkreceipt_version=2.1.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.
Kotlin version resolution: The Activation SDK may be compiled with a newer Kotlin version than what Expo uses. The
build.gradleforces a compatiblekotlin-stdlibandkotlinx-serializationversion across the entire project to avoid metadata conflicts:
rootProject.allprojects {
configurations.configureEach {
resolutionStrategy {
def kv = getKotlinVersion() // 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
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 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
// Parse TEST_OPTIONS: "Ads,Test" -> setOf(TestOptions.Ads, TestOptions.Test)
val testOptionsStr = meta?.getString("com.blinkengage.TEST_OPTIONS") ?: ""
val parsedTestOptions = testOptionsStr.split(",")
.filter { it.isNotBlank() }
.mapNotNull { name ->
when (name.trim()) {
"Ads" -> TestOptions.Ads
"Test" -> TestOptions.Test
else -> null
}
}
.toSet()
ActivationClient.instance.apply {
testOptions = parsedTestOptions
scanReward = ScanReward(baseReward)
rewardCurrencyName = currencyName
rewardPayoutPercentage = payoutPct.toDouble()
rewardCurrencyPerDollar = rewardCurrencyPerDollarProp.toDouble()
}
}
}
SDK debouncing: The SDK debounces property changes. Rapid sequential sets (e.g., the receipts SDK setting
deviceIdfollowed by the host app settinghashedEmail) 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_PER_DOLLAR = "100"
│ com.blinkengage.USER_PAYOUT_PERCENTAGE = "100"
│ com.blinkengage.BASE_REWARD = "10"
│ com.blinkengage.TEST_OPTIONS = "Ads,Test"
│
▼ (runtime — PackageManager.GET_META_DATA)
│
BlinkEngagePackage.kt → ActivationClient.instance
Configuration properties:
| Property | Meta-data key | Default | Description |
|---|---|---|---|
scanReward | com.blinkengage.BASE_REWARD | ScanReward(10) | Base reward amount emitted on every successful scan as Rewards.ScanFinished. |
rewardCurrencyName | com.blinkengage.REWARD_CURRENCY_NAME | "points" | Display name for the reward currency. |
rewardCurrencyPerDollar | com.blinkengage.REWARD_CURRENCY_PER_DOLLAR | 100.0 | Conversion rate from reward currency to actual currency (e.g. 100.0 means 100 points = $1). Used when the SDK converts backend payout amounts into in-app reward amounts for display. |
rewardPayoutPercentage | com.blinkengage.USER_PAYOUT_PERCENTAGE | 100.0 | Percentage of backend-calculated reward shown to the user. Android SDK default = RewardCurrency.REWARD_PAYOUT_PERCENTAGE_MAX (100.0). Valid range [40.0, 100.0] — values outside this are clamped by the setter. |
testOptions | com.blinkengage.TEST_OPTIONS | emptySet() | Parsed from a comma-separated string ("Ads,Test"). Combine TestOptions.Ads (test ad placements) and/or TestOptions.Test (test promotions). Development only — omit in production. |
Platform note — currency configuration: Reward math on both platforms is driven by the combination of
baseReward(scan reward),rewardCurrencyPerDollar(display conversion, e.g.100.0means 100 points = $1), anduserPayoutPercentage(backend-calculated promo / boost display). All three are configured per-platform from the same sharedapp.config.jsprops.
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
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:
| Function | Purpose |
|---|---|
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()
.options(ScanOptions.newBuilder().build())
.characteristics(
CameraCharacteristics.Builder()
.cameraPermission(true)
.build()
)
.activation(true)
.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")
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"
}
val amount = reward.amount
Log.d(TAG, "onRewardEarned: type=$rewardType, amount=$amount")
ActivationFlowState.onRewardEarned?.invoke(
mapOf("context" to rewardType, "amount" to amount),
)
}
// ======================================================================
// 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:
| Layer | What | Why |
|---|---|---|
ExpoView (outer) | Base class that Expo uses to host native views in RN | Makes 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 islauncher.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 toBlinkEngageModule. The module sets theonRewardEarnedcallback (which callssendEventto JS); this view invokes it when the SDK emits rewards.
How the scan + rewards flow works
- User taps "Scan Receipt" in the
OffersWallcomposable onScanReceiptfires → launches the camera activity with.activation(true)- 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, andRewards.Boostvia its internalRewardsManager
- Camera activity finishes → the launcher callback runs (for optional logging/error handling only)
- The
LaunchedEffectinOfferWallViewcollects the emitted rewards fromActivationClient.instance.rewardsand forwards them to JS viaActivationFlowState
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 Activations SDK is not present at runtime, the
.activation(true)flag is silently ignored and the camera returns results normally.
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
phonemust 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();
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:
| Field | Type | Description |
|---|---|---|
amount | number | Points earned for this reward |
context | string | The reward type: "ScanFinished", "Promo", "Boost", or "BarcodeCollection" |
scanResults | RewardScanResults? | Scan results data (only present for ScanFinished context) |
Note:
contextvalues are mapped from the SDK'sRewardssealed class inOfferWallView.kt— e.g.Rewards.Promotion→"Promo".
Reward types
| Type | Trigger | Source |
|---|---|---|
ScanFinished | Every successful receipt scan | ActivationClient.scanReward value (set in BlinkEngagePackage.kt) |
Promo (Promotion) | Scanned receipt matches a qualified promotion | Backend calculates based on rewardPayoutPercentage and matched offer values |
Boost | User watches a rewarded ad or redeems a CPA offer on the receipt summary screen | SDK emits when ad completes or CPA is redeemed |
BarcodeCollection | User scans a qualifying barcode | SDK emits when barcode collection reward is triggered |
Reward flow on Android
- User scans receipt via the offer wall → camera activity runs with
.activation(true) - Inside the camera activity,
ScanResultsFlowprocesses the receipt:QualifiedPromotionsUseCasechecksActivationClient.scanReward→ emitsRewards.ScanFinishedif > 0- Validates receipt against backend promotions → emits
Rewards.Promotionif matched earnings > 0
- Receipt summary shows boost offers → emits
Rewards.Boostwhen claimed OfferWallView'sLaunchedEffectcollects fromActivationClient.instance.rewardsand forwards to JS viaActivationFlowState.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
| Event | When It Fires | What It Contains |
|---|---|---|
ReceiptProcessed | After a receipt is validated and processed | Receipt data, promotion match results, fraud status, blink_receipt_id |
RewardUpdate | When a reward is calculated by the backend | Reward 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_idorblink_receipt_id+reward_typeto deduplicate. - Store
blink_receipt_id: This is the primary key that linksReceiptProcessed,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. Receipt Validation
The SDK validates receipts automatically before processing rewards. No configuration is required. Validation checks include:
- Duplicate detection — Receipts already submitted are rejected (cross-client, 14-day window).
- Fraud detection — Suspicious receipts are flagged.
- Receipt age — Receipts older than 14 days are rejected.
- Receipt date — Receipts without a parseable date are rejected.
All validation is handled internally by the SDK.
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: ['Ads', 'Test'], // Ads = test ad units, Test = test promotions
},
}
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(
TestOptions.Ads, // Use Google Ad Manager test ad units
TestOptions.Test, // Enable test mode (returns test promotions)
)
Logging
Enable SDK debug logging by adding the following to your project's local.properties:
logcat.state=enabled
This activates verbose logging across the BlinkReceipt and Activations SDKs, including network requests, scan session lifecycle, reward events, and receipt validation results.
13. Cleanup & Teardown
Close the ActivationClient when your application terminates. In our Expo CNG setup, this should be handled in the BlinkEngageLifecycleListener:
class BlinkEngageLifecycleListener : ApplicationLifecycleListener {
override fun onCreate(application: Application) {
// ... initialization (see Section 5.2)
}
override fun onDestroy(application: Application) {
BlinkReceiptSdk.terminate()
ActivationClient.instance.close()
}
}
In a bare React Native project, this would go in MainApplication.onTerminate():
override fun onTerminate() {
BlinkReceiptSdk.terminate()
ActivationClient.instance.close()
super.onTerminate()
}
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:
- Add
<meta-data>entries toAndroidManifest.xml:com.microblink.LicenseKey(BlinkReceipt license)com.microblink.ProductIntelligence(ifandroid.prodIntelKeywas provided)com.google.android.gms.ads.APPLICATION_ID(GAM App ID)com.blinkengage.REWARD_CURRENCY_NAME,REWARD_CURRENCY_PER_DOLLAR,USER_PAYOUT_PERCENTAGE,BASE_REWARDcom.blinkengage.TEST_OPTIONS(ifandroid.testOptionswas provided)
- Write
modules/blink-engage/android/sdk-versions.propertieswith theactivation_versionandblinkreceipt_versionthe module'sbuild.gradlewill resolve. - Copy any
themeIconslisted inapp.config.jsintoandroid/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
| Prop | Type | Required | Description |
|---|---|---|---|
licenseKey | string | Yes | BlinkReceipt license key (from .env) |
prodIntelKey | string | No | BlinkReceipt Product Intelligence key (enables PI features when present) |
activationVersion | string | No | com.actualplatform:activation Maven version (default: "1.0.0") |
blinkReceiptVersion | string | No | com.microblink.blinkreceipt:* Maven version (default: "2.1.0") |
engageSdkAutoInit | boolean | No | androidx.startup auto-init of the Activation SDK (default: true) |
testOptions | string[] | No | Subset of ["Ads", "Test"]. Development only — omit in production. |
Shared block (applies to both platforms)
| Prop | Type | Required | Description |
|---|---|---|---|
gadAppId | string | Yes | Google AdMob Application ID (from .env) |
rewardCurrencyName | string | No | Display name for rewards (default: "points") |
rewardCurrencyPerDollar | number | No | Conversion rate (e.g. 100 means 100 reward units = $1). Consumed on both platforms. (default: 100) |
userPayoutPercentage | number | No | Percentage (40–100) of backend-calculated reward shown to the user. (default: 100) — Android SDK clamps to [40, 100]; iOS SDK accepts a fraction [0.4, 1.0] (the plugin divides this prop by 100 before passing it to iOS). |
baseReward | number | No | Base scan reward — ActivationClient.scanReward = ScanReward(baseReward) (default: 10) |
themeIcons | object | No | Per-icon source paths; copied to Android drawables + iOS xcassets |
Environment Variables
| Variable | Description |
|---|---|
ANDROID_BLINK_LICENSE_KEY | BlinkReceipt license key |
ANDROID_PROD_INTEL_KEY | BlinkReceipt Product Intelligence key |
GAD_APP_ID | Google AdMob Application ID |
Android Meta-Data (written by plugin, read by BlinkEngagePackage.kt)
| Meta-data key | Source prop | Default | Maps to |
|---|---|---|---|
com.microblink.LicenseKey | android.licenseKey | — | BlinkReceipt license |
com.microblink.ProductIntelligence | android.prodIntelKey | — | BlinkReceipt PI features |
com.google.android.gms.ads.APPLICATION_ID | gadAppId | — | Google Mobile Ads AdMob App ID |
com.blinkengage.REWARD_CURRENCY_NAME | rewardCurrencyName | "points" | ActivationClient.rewardCurrencyName |
com.blinkengage.REWARD_CURRENCY_PER_DOLLAR | rewardCurrencyPerDollar | 100 | ActivationClient.rewardCurrencyPerDollar |
com.blinkengage.USER_PAYOUT_PERCENTAGE | userPayoutPercentage | 100 | ActivationClient.rewardPayoutPercentage |
com.blinkengage.BASE_REWARD | baseReward | 10 | ActivationClient.scanReward = ScanReward(baseReward) |
com.blinkengage.TEST_OPTIONS | android.testOptions | — | ActivationClient.testOptions (parsed Set<TestOptions>) |
Artifacts Generated by the Plugin (at prebuild time)
| Path | Written by | Purpose |
|---|---|---|
android/app/src/main/AndroidManifest.xml (meta-data) | withBlinkEngageAndroidManifest | Runtime config read by BlinkEngagePackage.kt |
modules/blink-engage/android/sdk-versions.properties | withBlinkEngageAndroidVersions | Maven versions consumed by the module's build.gradle |
android/app/src/main/res/drawable/*.png | withBlinkEngageAndroidAssets | Theme icons |
JS API Summary
| Method | Returns | Description |
|---|---|---|
setUser(params) | Promise<void> | Set user identity (auto-hashes PII) |
clearUser() | void | Clear 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) | EventSubscription | Subscribe to reward events; call .remove() to unsubscribe |
testBridgeConnection(msg) | string | Verify native bridge is working |
Components
| Component | Props | Description |
|---|---|---|
OfferWallView | style? | Embedded offer wall view |
16. Integration Checklist
Use this checklist before shipping to verify nothing was missed:
-
.envcontainsANDROID_BLINK_LICENSE_KEY,ANDROID_PROD_INTEL_KEY(if using PI), andGAD_APP_ID -
app.config.jshas theblink-engage/pluginregistered with correct nested props (ios,android, shared) -
npx expo prebuild --platform androidruns without errors -
AndroidManifest.xmlcontainscom.microblink.LicenseKey,com.google.android.gms.ads.APPLICATION_ID, andcom.blinkengage.*meta-data -
modules/blink-engage/android/sdk-versions.propertiesexists and lists the expectedactivation_version/blinkreceipt_version - Theme icons copied to
android/app/src/main/res/drawable/(ifthemeIconsis set) - BlinkReceipt SDK initializes (check logcat for
BlinkEngageInittag) - Activations SDK detected on classpath (auto-configured by
BlinkReceiptSdk.initialize()) - At least one user identifier set (
hashedEmailorhashedPhone) before showing offer wall -
rewardCurrencyName,userPayoutPercentage, andbaseRewardconfigured as desired -
OfferWallViewrenders correctly withstyle={{ flex: 1 }} - 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: ['Ads']during development) - Webhook endpoint receiving
ReceiptProcessedandRewardUpdateevents -
app-ads.txtconfigured and publicly accessible at your domain root -
android.testOptionsremoved (or empty) for production builds -
ActivationClient.close()called on app termination
17. Troubleshooting
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
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
- No ads on loading screen: The Activation SDK backend needs to return ad placements. During development, add
'Ads'toandroid.testOptionsinapp.config.js(writescom.blinkengage.TEST_OPTIONS→ applied at startup inBlinkEngagePackage.kt). - No promotion rewards: The backend needs to match promotions against the scanned receipt. During development, add
'Test'toandroid.testOptionsto get test promotions that match any receipt. - No scan rewards: Make sure
baseRewardinapp.config.jsis non-zero (it maps toActivationClient.scanReward = ScanReward(baseReward)). If0or missing,Rewards.ScanFinishedis not emitted. Verify thecom.blinkengage.BASE_REWARDmeta-data is present inAndroidManifest.xmlafter prebuild. - Activation flow not appearing: The camera fragment checks
ActivationBridge.available(Activation SDK on classpath) and requires a non-emptyblinkReceiptIdin the scan results. Check logcat for errors from the camera fragment.
Camera permission
The BlinkReceipt 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
OfferWallViewis mounted when scanning — rewards are collected in aLaunchedEffectinside the view. If the view is not mounted, no one collects fromActivationClient.instance.rewardsand events are lost. - Make sure
addRewardListeneris called before the user interacts with the offer wall.