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 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 prebuildnpx 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 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

  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. Receipt Validation
  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+
  • Kotlin 2.1.20+ (set via expo-build-properties or root build.gradle)
  • Jetpack Compose enabled (the module's build.gradle handles 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 key
    • ANDROID_PROD_INTEL_KEY — BlinkReceipt Product Intelligence key (optional, enables PI features)
    • GAD_APP_ID — Google AdMob Application 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.tsplugin/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
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 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',
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:

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.

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.gradle forces a compatible kotlin-stdlib and kotlinx-serialization version 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 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_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:

PropertyMeta-data keyDefaultDescription
scanRewardcom.blinkengage.BASE_REWARDScanReward(10)Base reward amount emitted on every successful scan as Rewards.ScanFinished.
rewardCurrencyNamecom.blinkengage.REWARD_CURRENCY_NAME"points"Display name for the reward currency.
rewardCurrencyPerDollarcom.blinkengage.REWARD_CURRENCY_PER_DOLLAR100.0Conversion 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.
rewardPayoutPercentagecom.blinkengage.USER_PAYOUT_PERCENTAGE100.0Percentage 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.
testOptionscom.blinkengage.TEST_OPTIONSemptySet()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.0 means 100 points = $1), and userPayoutPercentage (backend-calculated promo / boost display). All three are configured per-platform from the same shared app.config.js props.

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:

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()
.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:

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
  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 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 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();

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
contextstringThe reward type: "ScanFinished", "Promo", "Boost", or "BarcodeCollection"
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 types

TypeTriggerSource
ScanFinishedEvery successful receipt scanActivationClient.scanReward value (set in BlinkEngagePackage.kt)
Promo (Promotion)Scanned receipt matches a qualified promotionBackend calculates based on rewardPayoutPercentage 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
BarcodeCollectionUser scans a qualifying barcodeSDK emits when barcode collection reward is triggered

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.onRewardEarnedBlinkEngageModule.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. 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:

  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_PER_DOLLAR, USER_PAYOUT_PERCENTAGE, 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)
activationVersionstringNocom.actualplatform:activation Maven version (default: "1.0.0")
blinkReceiptVersionstringNocom.microblink.blinkreceipt:* Maven version (default: "2.1.0")
engageSdkAutoInitbooleanNoandroidx.startup auto-init of the Activation SDK (default: true)
testOptionsstring[]NoSubset of ["Ads", "Test"]. Development only — omit in production.

Shared block (applies to both platforms)

PropTypeRequiredDescription
gadAppIdstringYesGoogle AdMob Application ID (from .env)
rewardCurrencyNamestringNoDisplay name for rewards (default: "points")
rewardCurrencyPerDollarnumberNoConversion rate (e.g. 100 means 100 reward units = $1). Consumed on both platforms. (default: 100)
userPayoutPercentagenumberNoPercentage (40100) 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).
baseRewardnumberNoBase scan reward — ActivationClient.scanReward = ScanReward(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
GAD_APP_IDGoogle AdMob Application ID

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

Meta-data keySource propDefaultMaps to
com.microblink.LicenseKeyandroid.licenseKeyBlinkReceipt license
com.microblink.ProductIntelligenceandroid.prodIntelKeyBlinkReceipt PI features
com.google.android.gms.ads.APPLICATION_IDgadAppIdGoogle Mobile Ads AdMob App ID
com.blinkengage.REWARD_CURRENCY_NAMErewardCurrencyName"points"ActivationClient.rewardCurrencyName
com.blinkengage.REWARD_CURRENCY_PER_DOLLARrewardCurrencyPerDollar100ActivationClient.rewardCurrencyPerDollar
com.blinkengage.USER_PAYOUT_PERCENTAGEuserPayoutPercentage100ActivationClient.rewardPayoutPercentage
com.blinkengage.BASE_REWARDbaseReward10ActivationClient.scanReward = ScanReward(baseReward)
com.blinkengage.TEST_OPTIONSandroid.testOptionsActivationClient.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 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)
  • BlinkReceipt SDK initializes (check logcat for BlinkEngageInit tag)
  • Activations SDK detected on classpath (auto-configured by BlinkReceiptSdk.initialize())
  • At least one user identifier set (hashedEmail or hashedPhone) before showing offer wall
  • rewardCurrencyName, userPayoutPercentage, and baseReward configured as desired
  • OfferWallView renders correctly with style={{ 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 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

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

  1. No ads on loading screen: The Activation SDK backend needs to return ad placements. During development, add 'Ads' to android.testOptions in app.config.js (writes com.blinkengage.TEST_OPTIONS → applied at startup in BlinkEngagePackage.kt).
  2. No promotion rewards: The backend needs to match promotions against the scanned receipt. During development, add 'Test' to android.testOptions to get test promotions that match any receipt.
  3. No scan rewards: Make sure baseReward in app.config.js is non-zero (it maps to ActivationClient.scanReward = ScanReward(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.
  4. Activation flow not appearing: The camera fragment checks ActivationBridge.available (Activation SDK on classpath) and requires a non-empty blinkReceiptId in 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 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.