Android React Native - Integration Guide
This section covers integrating the Activation SDK into a React Native application on Android. The Activation SDK is a native Android library built with Jetpack Compose. React Native integration requires a native module bridge to launch the offer wall and receive callbacks.
Additional Prerequisites
- React Native 0.72+
- Kotlin 2.2 or newer in your Android project, with 2.3.x recommended. The Activation SDK is built with Kotlin 2.3, and a Kotlin compiler can only read metadata from one minor version above itself, so older versions fail with an "incompatible version of Kotlin" error. See the Android Activation Prerequisites for the full requirements.
- Jetpack Compose dependencies (the Activation SDK bundles its own Compose runtime, but your app's Gradle must support it)
Additional Dependencies
In your React Native project's android/app/build.gradle, add the Compose dependencies alongside the Activation SDK dependencies listed above:
android {
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
}
dependencies {
// Compose dependencies (if not already present)
implementation platform("androidx.compose:compose-bom:2025.01.01")
implementation "androidx.compose.material3:material3"
implementation "androidx.activity:activity-compose:1.10.1"
}
Initialize in Application Class
In your MainApplication.kt, initialize the SDK and configure the ActivationClient. User identity can be set here or later from JavaScript via the native module.
import com.microblink.BlinkReceiptSdk
import com.actualplatform.activation.ActivationClient
import okio.ByteString.Companion.encodeUtf8
class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
BlinkReceiptSdk.initialize(this, object : InitializeCallback {
override fun onComplete() {
// ActivationClient is automatically configured with deviceId
// Set user identity here or later from JavaScript via the native module
}
override fun onException(throwable: Throwable) {
Log.e("App", "SDK init failed", throwable)
}
})
}
override fun onTerminate() {
BlinkReceiptSdk.terminate()
ActivationClient.instance.close()
super.onTerminate()
}
}
Creating the Native Module
Create a native module that exposes the Activation SDK to JavaScript. This bridge handles user identity, configuration, launching the offer wall, and listening for rewards.
Create ActivationsModule.kt in android/app/src/main/java/com/yourapp/:
package com.yourapp
import android.app.Activity
import android.content.Intent
import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.RewardCurrency
import com.actualplatform.activation.RewardCurrencyCodePosition
import com.actualplatform.activation.RewardCurrencyImageLocation
import com.actualplatform.activation.RewardCurrencyLabelStyle
import com.actualplatform.activation.RewardCurrencyMessagingTextStyle
import com.actualplatform.activation.RewardCurrencyRounding
import com.actualplatform.activation.RewardPoint
import com.actualplatform.activation.ScanReward
import com.actualplatform.activation.networking.HttpEnvironment
import com.actualplatform.activation.networking.TestOptions
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlinx.coroutines.*
import okio.ByteString.Companion.encodeUtf8
class ActivationsModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var rewardListenerActive = false
companion object {
const val NAME = "ActivationsModule"
const val REQUEST_CODE_ACTIVATIONS = 9001
}
override fun getName(): String = NAME
// --- User Identity ---
@ReactMethod
fun setUserIdentity(email: String?, phone: String?) {
ActivationClient.instance.apply {
hashedEmail = email?.takeIf { it.isNotEmpty() }
?.encodeUtf8()?.sha256()?.hex()
hashedPhone = phone?.takeIf { it.isNotEmpty() }
?.encodeUtf8()?.sha256()?.hex()
}
}
// --- Configuration ---
@ReactMethod
fun configure(config: ReadableMap) {
val client = ActivationClient.instance
if (config.hasKey("testMode") && config.getBoolean("testMode")) {
// Enables test mode: returns test promotions from the backend and
// registers this device as a Google Ad Manager test device, so ad
// units serve test creatives.
client.testOptions = setOf(TestOptions.Test)
}
// Reward currency — build a single RewardCurrency.default(...) from the
// JS config. RewardCurrency has no public constructor; the factory
// normalizes every value (name trimmed to 8 chars, payout clamped to
// 0.4…1.0). Enums arrive as strings; map them with `when` helpers.
client.rewardCurrency = RewardCurrency.default(
currencyName = config.getString("rewardCurrencyName") ?: "points",
currencyCode = config.getString("currencyCode"),
currencyCodePosition = when (config.getString("currencyCodePosition")) {
"Trailing" -> RewardCurrencyCodePosition.Trailing
else -> RewardCurrencyCodePosition.Leading
},
currencyPerDollar = if (config.hasKey("rewardCurrencyPerDollar"))
config.getDouble("rewardCurrencyPerDollar") else 100.0,
// JS sends a 0–100 percentage; the SDK expects a 0–1 fraction.
userPayoutPercentage = if (config.hasKey("userPayoutPercentage"))
config.getDouble("userPayoutPercentage") / 100.0 else 0.6,
rewardCurrencyLabelStyle = when (config.getString("rewardLabelStyle")) {
"CurrencyName" -> RewardCurrencyLabelStyle.CurrencyName
"CurrencyCode" -> RewardCurrencyLabelStyle.CurrencyCode
else -> RewardCurrencyLabelStyle.CurrencyImage
},
rewardCurrencyMessagingTextStyle = when (config.getString("rewardMessagingStyle")) {
"CurrencyCode" -> RewardCurrencyMessagingTextStyle.CurrencyCode
"NoAmount" -> RewardCurrencyMessagingTextStyle.NoAmount
else -> RewardCurrencyMessagingTextStyle.CurrencyName
},
rewardRounding = when (config.getString("rewardRounding")) {
"Decimal" -> RewardCurrencyRounding.Decimal
else -> RewardCurrencyRounding.Whole
},
currencyImageLocations = config.getArray("rewardImageLocations")
?.toArrayList()
?.mapNotNull { name ->
when (name as? String) {
"OfferWallItem" -> RewardCurrencyImageLocation.OfferWallItem
"ReceiptTotalReward" -> RewardCurrencyImageLocation.ReceiptTotalReward
"ReceiptBoost" -> RewardCurrencyImageLocation.ReceiptBoost
"ReceiptTask" -> RewardCurrencyImageLocation.ReceiptTask
else -> null
}
}
?.toSet()
?: RewardCurrencyImageLocation.ALL,
)
// Base scan reward — wrapped in RewardPoint (null means no scan reward).
if (config.hasKey("baseReward")) {
client.scanReward = ScanReward(
reward = RewardPoint(config.getDouble("baseReward")),
)
}
}
// --- Launch Offer Wall ---
@ReactMethod
fun showOffersWall(promise: Promise) {
val activity = currentActivity
if (activity == null) {
promise.reject("NO_ACTIVITY", "No current activity")
return
}
val intent = Intent(activity, ActivationsComposeActivity::class.java)
activity.startActivityForResult(intent, REQUEST_CODE_ACTIVATIONS)
promise.resolve(null)
}
// --- Reward Listener ---
@ReactMethod
fun startRewardListener() {
if (rewardListenerActive) return
rewardListenerActive = true
scope.launch {
ActivationClient.instance.rewards.collect { reward ->
val params = Arguments.createMap().apply {
putDouble("amount", reward.amount.value)
putString("type", reward::class.simpleName ?: "Unknown")
}
sendEvent("onRewardEarned", params)
}
}
}
@ReactMethod
fun stopRewardListener() {
rewardListenerActive = false
scope.coroutineContext.cancelChildren()
}
// --- Event Emitter ---
private fun sendEvent(eventName: String, params: WritableMap) {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}
@ReactMethod
fun addListener(eventName: String) { /* Required for RN */ }
@ReactMethod
fun removeListeners(count: Int) { /* Required for RN */ }
override fun invalidate() {
scope.cancel()
super.invalidate()
}
}
startRewardListener forwards reward::class.simpleName as-is, so it needs no code change for hard currency mode — the two new reward subtypes just arrive as type: "BoostCreditEarned" and type: "BoostCreditApplied". BoostCreditEarned is a pending balance (a rewarded boost banked instead of paid out immediately); BoostCreditApplied is the settlement against a later receipt. Hard currency mode is enabled per app by the Actual team — no host-app configuration is required, but your RewardEvent.type handling on the JS side should account for both.
The configure method above maps the JS config to a single RewardCurrency.default(...) object. RewardCurrency normalizes every value (currency name trimmed to 8 characters, userPayoutPercentage clamped to 0.4…1.0) and reward amounts are typed as RewardPoint (read the raw number via .value). For the full field semantics, display-style enums, per-surface icon visibility, and payout math, see the Android Reward Currency reference — the Android Native and React Native integrations share the same SDK API.
Creating the Activations Activity
The offer wall requires a Compose-based Activity. Create a lightweight wrapper.
Create ActivationsComposeActivity.kt:
package com.yourapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.material3.MaterialTheme
import com.actualplatform.activation.OffersWall
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
class ActivationsComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
val launcher = rememberLauncherForActivityResult(
contract = CameraRecognizerContract(),
) { result ->
when (result) {
is CameraRecognizerResults.Success -> {
// Post-scan flow handled via activation(true)
}
is CameraRecognizerResults.Exception -> {
// Handle error
}
CameraRecognizerResults.Cancelled -> {
// User cancelled
}
}
}
OffersWall(
onScanReceipt = {
// Launch and return. Do NOT await the result — see the warning below.
launcher.launch(
CameraRecognizerOptions.Builder()
.options(ScanOptions.newBuilder().activation(true).build())
.characteristics(CameraCharacteristics.Builder().build())
.build()
)
},
onDismiss = { finish() },
)
}
}
}
}
Do not hold the scan session open across the camera launch. onScanReceipt is a suspend function, so awaiting the launcher result looks like the intended contract — but it keeps a session open that the camera then rides along on instead of owning, and an unowned session is never registered with Actual's backend. A device-tier review request (a missing date or total, an unreadable photo) is filed before the receipt reaches processing, so it becomes the first thing to reference that session and fails with "Scan session not found". Nothing throws and nothing reaches an error callback; the shopper simply cannot submit the review. Launch the camera and return, as above. Addressed in a later release — see Scan sessions.
Register this activity in android/app/src/main/AndroidManifest.xml:
<activity
android:name=".ActivationsComposeActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
adjustResize is requiredThe review screens introduced in 1.2.0 contain text fields, and they inset themselves for the software keyboard from the window's IME insets. adjustResize on the Activity hosting the offer flow is what delivers those insets — without it the keyboard covers the form's input and submit button, and the shopper cannot complete a review request. See Error Flows.
Registering the Native Module
Create ActivationsPackage.kt:
package com.yourapp
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ActivationsPackage : ReactPackage {
override fun createNativeModules(
reactContext: ReactApplicationContext
): List<NativeModule> {
return listOf(ActivationsModule(reactContext))
}
override fun createViewManagers(
reactContext: ReactApplicationContext
): List<ViewManager<*, *>> {
return emptyList()
}
}
Register the package in MainApplication:
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages.toMutableList()
packages.add(ActivationsPackage())
return packages
}
JavaScript Bridge
Create ActivationsModule.ts in your React Native project:
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
const { ActivationsModule } = NativeModules;
interface ActivationsConfig {
environment?: 'production' | 'staging' | 'development';
// Enables test mode: returns test promotions and registers this device
// as a Google Ad Manager test device, so ad units serve test creatives.
testMode?: boolean;
// Reward currency — mapped to RewardCurrency.default(...) natively.
// See the Android Reward Currency reference for full field semantics.
rewardCurrencyName?: string; // max 8 chars; blank → "points"
currencyCode?: string; // e.g. "$", "€"; omit to disable code styles
currencyCodePosition?: 'Leading' | 'Trailing';
rewardCurrencyPerDollar?: number; // currency units per $1 (default 100)
userPayoutPercentage?: number; // 0–100; native ÷100 → clamped 0.4…1.0
rewardLabelStyle?: 'CurrencyImage' | 'CurrencyName' | 'CurrencyCode';
rewardMessagingStyle?: 'CurrencyName' | 'CurrencyCode' | 'NoAmount';
rewardRounding?: 'Whole' | 'Decimal';
rewardImageLocations?: Array<
'OfferWallItem' | 'ReceiptTotalReward' | 'ReceiptBoost' | 'ReceiptTask'
>; // omit → all surfaces
baseReward?: number; // base scan reward → ScanReward(RewardPoint(...))
}
interface RewardEvent {
amount: number;
// BoostCreditEarned/BoostCreditApplied (Activation SDK 1.1.2+) only fire for apps
// with hard currency mode enabled — see the Android SDK 1.1.2 release notes.
type: 'ScanFinished' | 'Promotion' | 'Boost' | 'BoostCreditEarned' | 'BoostCreditApplied';
}
const eventEmitter = new NativeEventEmitter(ActivationsModule);
export const Activations = {
setUserIdentity: (email?: string, phone?: string) => {
ActivationsModule.setUserIdentity(email ?? null, phone ?? null);
},
configure: (config: ActivationsConfig) => {
ActivationsModule.configure(config);
},
showOffersWall: (): Promise<void> => {
return ActivationsModule.showOffersWall();
},
onRewardEarned: (callback: (event: RewardEvent) => void) => {
ActivationsModule.startRewardListener();
const subscription = eventEmitter.addListener('onRewardEarned', callback);
return () => {
subscription.remove();
ActivationsModule.stopRewardListener();
};
},
};
Usage Example
import React, { useEffect, useState } from 'react';
import { SafeAreaView, View, Button, Text, StyleSheet, Alert } from 'react-native';
import { Activations } from './ActivationsModule';
export default function App() {
const [rewards, setRewards] = useState<Array<{ amount: number; type: string }>>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
// One-time setup
Activations.setUserIdentity('user@example.com', '+15551234567');
Activations.configure({
rewardCurrencyName: 'Points',
rewardCurrencyPerDollar: 100, // 100 points = $1
userPayoutPercentage: 100, // 0–100; native ÷100 → 1.0 (clamped 0.4…1.0)
baseReward: 10, // base scan reward
});
// Start listening for rewards
const unsubscribe = Activations.onRewardEarned((event) => {
setRewards((prev) => [...prev, event]);
setTotal((prev) => prev + event.amount);
});
return unsubscribe;
}, []);
const openPromotions = async () => {
try {
setRewards([]);
setTotal(0);
await Activations.showOffersWall();
} catch (error) {
Alert.alert('Error', String(error));
}
};
return (
<SafeAreaView>
<Text>Activations Demo</Text>
<Button title="Open Promotions" onPress={openPromotions} />
<View>
<Text>Total Rewards: {total.toFixed(2)} Points</Text>
{rewards.map((r, i) => (
<Text key={i}>{r.type}: +{r.amount.toFixed(2)}</Text>
))}
</View>
</SafeAreaView>
);
}
setUserIdentity() does not confirm registrationsetUserIdentity() is fire-and-forget — it sets hashedEmail/hashedPhone on the native ActivationClient and returns immediately, with no signal for when (or whether) registration with the backend actually completes. The native SDK exposes that as ActivationClient.registrationState (see Registration State in the native docs), but ActivationsModule above does not bridge it. If the offer wall shows no promotions right after calling setUserIdentity(), the debounced registration call may simply still be in flight — bridge registrationState as an event (mirroring startRewardListener/onRewardEarned above) if your app needs to observe it from JS.
Camera Permission
The Paper Receipt camera requires android.permission.CAMERA. Request this permission in your React Native code before launching the offer wall:
import { PermissionsAndroid } from 'react-native';
await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.CAMERA
);
Error Flows
A receipt the SDK can't accept does not end the flow or come back as an error. Each problem — an unreadable photo, a missing date or total, a receipt outside the rewards window, a submission limit, a duplicate, or a fraud flag — raises its own screen that explains what happened and offers to send the receipt to a human reviewer. Dismissing that screen returns the shopper to the offer wall.
There is nothing to implement in your native module or JavaScript bridge. Because this integration pairs OffersWall with .activation(true) on ScanOptions, the camera activity presents these screens itself and handles "Retake photo" by resuming its own capture session — so the receipt keeps the same blinkReceiptId across retakes with no host-app code. The onResumeScan and onScanAccepted parameters added in 1.2.0 apply only to OffersWallFlow, which this guide does not use.
Two things do reach your integration:
- The scan reward is deferred for any flow that ends at a review request. The backend grants it only if a reviewer accepts, arriving later as a
RewardUpdatewebhook rather than through youronRewardEarnedlistener. Don't treat the on-device reward events as final. - A correction the shopper types is never merged into the scan. It travels to the reviewer only, so a field that was missing at scan time is still missing on the receipt summary.
See Error Flows for how the checks are ordered and when rewards land, and Error Flows Summary for what the shopper sees in each case.
Error Flows ship in Activation 1.2.0. Confirm your com.actualplatform:activation-bom version and add android:windowSoftInputMode="adjustResize" to the Activity hosting the offer wall — see Creating the Activations Activity.
Logging
Enable SDK debug logging by adding the following to your project's local.properties:
logcat.state=enabled
In a React Native project, local.properties lives inside the native Android project, not at the repo root — i.e. ./<root>/android/local.properties. If the file doesn't exist yet, create it.
This activates verbose logging across the Paper Receipt and Activation SDKs, including network requests, scan session lifecycle, reward events, and receipt validation results.
React Native Troubleshooting
Compose Version Conflicts
If you see Compose compiler version errors, ensure your kotlinCompilerExtensionVersion matches your Kotlin version. The Activation SDK bundles its own Compose dependencies, which Gradle will resolve against your project's BOM.
Missing ActivationClient at Runtime
If the app crashes with ClassNotFoundException for ActivationClient, ensure:
- The
implementationdependency is in your app-levelbuild.gradle(notcompileOnly) - The Maven repository is correctly configured
- Run
./gradlew app:dependenciesto verify resolution
Reward Events Not Received
- Ensure
startRewardListener()is called before opening the offer wall - Check that the
NativeEventEmittersubscription is active - Rewards are only emitted during an active scan session (after scanning a receipt)
React Native Testing Checklist
- Paper Receipt SDK initialized in
MainApplication - Activation SDK detected automatically on classpath
- Native module registered in
MainApplicationpackages ActivationsComposeActivityregistered inAndroidManifest.xml- At least one user identifier set (hashed email or hashed phone)
- Reward currency configured via
RewardCurrency(name, payout, per-dollar, display styles) - Reward events received in JavaScript via
onRewardEarned - Offer Wall presenting correctly via
showOffersWall() - Receipt scan completing and showing reward summary
- Boost ads displaying (use
testMode: truefor test ad units) - Webhook endpoint receiving
ReceiptProcessedandRewardUpdateevents - Missed earnings correction flow accessible
ActivationsComposeActivitydeclaresandroid:windowSoftInputMode="adjustResize", and review-form input stays visible with the keyboard uponScanReceiptlaunches the camera and returns — it does not await the launcher result- Error-flow screens reachable: unreadable photo, missing date/total, receipt outside the rewards window
- A device-tier review (missing date or total) submits successfully — no "Scan session not found"
- Server verdicts (duplicate, submission limit, fraud) exercised with test mode off —
testMode: trueaccepts everything - Camera permission requested before launching offer wall
- Debug/test options disabled for production build
app-ads.txtconfigured and publicly accessible (see GAM MCM Setup)