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 enabled in your Android project
- 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("testAds")) {
val options = mutableSetOf<TestOptions>()
if (config.getBoolean("testAds")) options.add(TestOptions.Ads)
if (config.hasKey("testMode") && config.getBoolean("testMode")) {
options.add(TestOptions.Test)
}
client.testOptions = options
}
// 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()
}
}
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 = {
launcher.launch(
CameraRecognizerOptions.Builder()
.options(ScanOptions.newBuilder().activation(true).build())
.characteristics(CameraCharacteristics.Builder().build())
.build()
)
},
onDismiss = { finish() },
)
}
}
}
}
Register this activity in android/app/src/main/AndroidManifest.xml:
<activity
android:name=".ActivationsComposeActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
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';
testAds?: boolean;
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;
type: 'ScanFinished' | 'Promotion' | 'Boost';
}
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>
);
}
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
);
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
testAds: truefor test ad units) - Webhook endpoint receiving
ReceiptProcessedandRewardUpdateevents - Missed earnings correction flow accessible
- Camera permission requested before launching offer wall
- Debug/test options disabled for production build
app-ads.txtconfigured and publicly accessible (see GAM MCM Setup)