expo-tnk
v1.4.1
Published
Tnk Factory Offerwall SDK module for Expo
Maintainers
Readme
expo-tnk
Tnk Factory Reward SDK (Android) offerwall module for Expo.
- iOS: TnkRwdSdk2 v5.88 (vendored xcframework)
- Android:
com.tnkfactory:rwd8.09.07
Two ways to serve ads:
- Offerwall — the SDK's full-screen campaign list, with items re-skinned natively (icon / brand caption / bold action title / primary reward button).
- Self-rendered campaigns — placement campaign lists exposed as plain data (or
the ready-made
<CampaignList />), rendered directly in React Native.
The public API is aligned with expo-adpopcorn:
setUserId / openOfferwall(options?) / addOfferwallClosedListener /
loadPlacements / participateCampaign(campaign) / openCampaignDetail(campaign) /
getTotalReward / <CampaignList /> share the same names and shapes.
Installation
npx expo install expo-tnkConfiguration
Add the config plugin to your app config:
{
"expo": {
"plugins": [
[
"expo-tnk",
{
"iosAppId": "YOUR_IOS_APP_ID",
"androidAppId": "YOUR_ANDROID_APP_ID"
}
]
]
}
}| Prop | Platform | Description |
| --- | --- | --- |
| iosAppId | iOS | Tnk app id issued on the Tnk console. Written to Info.plist as tnkad_app_id; the SDK initializes itself from it, so there is no runtime init call. |
| androidAppId | Android | Same, written as <meta-data> to AndroidManifest. |
| photoLibraryDescription | iOS | Optional. Adds NSPhotoLibraryUsageDescription. Required if the publisher serves ads that ask the user to attach a proof image — the SDK opens the photo picker for those and iOS crashes without the key. |
| cameraDescription | iOS | Optional. Adds NSCameraUsageDescription. Same reason as above. |
Everything else ships with the library itself: Android permissions
(INTERNET, ACCESS_WIFI_STATE, AD_ID) and the AdWallActivity declaration.
The plugin also adds the Tnk maven repository to the project-level build.gradle —
Gradle resolves com.tnkfactory:rwd against the app's repository list, so the
repository declared inside the library module alone is not enough. If you manage
android/ yourself without prebuild, add it manually:
allprojects {
repositories {
maven { url 'https://repository.tnkad.net:8443/repository/public/' }
}
}ATT is intentionally not handled here — request tracking permission in the host app.
Then rebuild the native projects:
npx expo prebuildTesting: while the app is in test state on the Tnk console, ads only appear on registered test devices.
Quick start
import * as Tnk from "expo-tnk";
// Required once, before any other call — ads are not served without a user id.
await Tnk.setUserId(hashedUserId);
// Full-screen offerwall
await Tnk.openOfferwall();
// Inline campaign list
<Tnk.CampaignList placementIds={["main_feed"]} />;Usage
1. Set the user id (required)
await Tnk.setUserId("user-1234");- Must be called before the offerwall or any placement is used; without it the SDK serves no ads at all.
- The value is delivered back to your server in reward postbacks (
md_user_nm), so use the same stable id your backend knows. - Hash it first if it is personal information (email, phone number).
2. Offerwall
await Tnk.openOfferwall({
title: "충전소", // iOS navigation title
});- Resolves when the offerwall is presented, not when it closes.
titleis iOS only; the Android offerwall activity manages its own header.- Campaign list items are rendered natively with the library's built-in design; the SDK's top menu/filter bars are kept as-is. Curation sections are disabled on Android to keep the list flat.
Events:
useEffect(() => {
const closed = Tnk.addOfferwallClosedListener(() => {
refreshPointBalance(); // typical use: re-query points when the user returns
});
const clicked = Tnk.addCampaignClickedListener(({ appId, appName }) => {
analytics.track("offerwall_campaign_click", { appId, appName });
});
return () => {
closed.remove();
clicked.remove();
};
}, []);addOfferwallClosedListener— fired when the offerwall screen closes. On Android the SDK has no close callback, so this is detected when the host activity returns to the foreground after the offerwall was opened.addCampaignClickedListener— purely informational (analytics). The SDK fully handles the click itself (detail page, store landing, reward flow); you never need to act on this event.
Offerwall entry button:
const { count, total } = await Tnk.getTotalReward();
// e.g. hide the entry button when count === 0,
// or show "지금 3,200원 적립 가능!" from total3. Self-rendered campaigns
Placements are configured per-surface on the Tnk console ([관리자] → [매체관리] → [지면설정]). The library loads them headlessly and hands you plain data — no native view is shown, so everything is rendered 100% in React Native.
The flow is: loadPlacements (list data, which also carries everything a detail
screen needs) → participateCampaign (the CTA action) — with openCampaignDetail
as the SDK-UI fallback. <CampaignList /> bundles the flow into one component.
<CampaignList />
<Tnk.CampaignList
placementIds={["main_feed", "main_feed_2"]}
scrollEnabled={false} // when embedded in an outer ScrollView
onLoad={(campaigns) => console.log(campaigns.length)}
onError={(error) => console.warn(error)}
/>| Prop | Default | Description |
| --- | --- | --- |
| placementIds | (required) | Placement ids from the Tnk console. The server caps each placement at 20 campaigns, so several placements are merged into one list (deduped by appId, in the given order). Reloads when it changes. |
| renderItem | built-in item | (campaign: TnkCampaign) => ReactElement \| null. Replaces the default item UI. |
| onItemPress | participateCampaign | (campaign: TnkCampaign) => void. Replaces the default press behavior — e.g. navigate to your own detail screen. |
| onLoad | — | Called with the merged TnkCampaign[]. |
| onError | — | Called when loading or the default press action fails. |
| scrollEnabled | FlatList default | Pass false inside another scroll view. |
| style / contentContainerStyle | — | Forwarded to the underlying FlatList. |
The default item matches the offerwall's native item design (icon / brand caption / bold action phrase / reward button), and the default press participates directly (the SDK consent alert may appear once — see below). Custom rendering keeps the default press behavior only if you wire it yourself:
<Tnk.CampaignList
placementIds={["main_feed"]}
onItemPress={(campaign) => router.push(`/campaign/${campaign.appId}`)}
renderItem={(campaign) => (
<MyCampaignRow
icon={campaign.iconUrl || campaign.imageUrl}
brand={campaign.title}
action={Tnk.campaignActionText(campaign.campaignType)}
reward={`${campaign.reward.toLocaleString()}${campaign.rewardUnit}`}
/>
)}
/>Note: with a custom renderItem you own the press handling — wrap your row in a
Pressable and either pass onItemPress or call Tnk.participateCampaign(campaign)
yourself.
Headless
const campaigns = await Tnk.loadPlacements(["main_feed", "main_feed_2"]);
// campaigns: TnkCampaign[] — the placements merged into one deduped list.
// Placements that fail to load are skipped; rejects only when every one fails.Render the list — and a detail screen, if you want one — from the campaign data
(title, description, reward, rewardUnit, campaignType, …), then run the
CTA action when the user taps join:
<Button
title={`${campaign.reward}${campaign.rewardUnit} 받기`}
onPress={async () => {
try {
await Tnk.participateCampaign(campaign);
} catch (error) {
// consent declined / ended / already completed
}
}}
/>participateCampaignregisters the participation and opens the store page or the advertiser landing — no SDK UI is shown.- Privacy consent is required before participating. By default the SDK's consent
alert appears once; to keep the flow fully custom, collect consent in your own UI
and call
Tnk.setPrivacyConsent(true)beforehand — then no SDK UI is ever shown. - The 참여방식/유의사항 texts on the SDK's detail page are client-side templates,
not per-ad server data — build your own from
campaignType(the screenshots of other offerwall apps do exactly this).campaign.descriptionmay be empty — fall back to acampaignActionText(campaign.campaignType)-based template.
SDK detail page fallback
Multi-reward (isMultiReward) campaigns have per-step sub-rewards that only the SDK
detail page can enumerate; participateCampaign joins the currently active step.
Route those campaigns to the SDK page instead:
try {
await Tnk.openCampaignDetail(campaign);
} catch (error) {
// ended / already completed / privacy consent declined (iOS)
}TnkCampaign fields:
type TnkCampaign = {
appId: number; // unique campaign id
placementId: string; // placement the campaign was loaded from
title: string; // brand title, "[...]"/"(...)" annotations stripped
imageUrl: string; // creative image — can be a wide banner
iconUrl: string; // square icon; may be empty — fall back to imageUrl
reward: number; // reward amount (multiplier events applied)
originalReward: number; // pre-multiplier reward, 0 outside events
rewardUnit: string; // e.g. "원"
campaignType: number; // e.g. 206 — see campaignActionText()
campaignTypeName: string; // server-provided type name, e.g. "유튜브 구독"
description: string; // participation description; may be empty — provide a template fallback
isMultiReward: boolean;
productPrice: number; // CPS only
originalProductPrice: number; // CPS only
discountRate: number; // CPS only, percent
isFavorite: boolean; // CPS only
};Built-in campaign item design
Offerwall items (native) and the CampaignList default item share one design,
fixed to light theme:
- Brand caption — 13pt
#4B5563, the campaign title with[...]/(...)annotations stripped (e.g."캣스토랑 : 고양이 셰프 (멀티액션)"→"캣스토랑 : 고양이 셰프"). - Action title — 16pt semibold
#1F2937, derived from the campaign type code (206→"구독하기",100→"설치하기", fallback"확인하기"). The same mapping is exported ascampaignActionText(). - Reward button —
#3B82F6, radius 6, 34pt tall, white 13pt semibold,{reward}{unit}(e.g.180원). In the offerwall, non-actionable states dim the button and show a status label instead (설치 확인 / 적립 완료 / 종료 / 내일 가능).
Rewards
Reward payout happens server-to-server: configure the callback URL on the Tnk
console ([매체 관리] → 포인트 관리 → 자체서버에서 관리). Tnk calls your server with
seq_id / pay_pnt / md_user_nm / md_chk on every payout — deduplicate by
seq_id and verify md_chk (md5(app_key + md_user_nm + seq_id)). No reward
amount is ever delivered to the client.
API reference
setUserId(userId: string): Promise<void>;
openOfferwall(options?: OfferwallOptions): Promise<void>; // { title? } — iOS only
addOfferwallClosedListener(listener: () => void): EventSubscription;
addCampaignClickedListener(listener: (event: CampaignClickedEvent) => void): EventSubscription;
getTotalReward(): Promise<{ count: number; total: number }>;
loadPlacements(placementIds: string[]): Promise<TnkCampaign[]>;
participateCampaign(campaign: TnkCampaign): Promise<void>; // headless CTA action
openCampaignDetail(campaign: TnkCampaign): Promise<void>; // SDK detail page fallback
<CampaignList placementIds renderItem? onItemPress? onLoad? onError? scrollEnabled? style? />;
setPrivacyConsent(agreed: boolean): void;
campaignActionText(campaignType: number): string;All promise-returning APIs reject with a coded error (CodedException on Android,
Exception on iOS) whose message includes the SDK error detail. Everything is a
no-op or throws UnavailabilityError on web.
Example
See example/App.tsx. Fill in the app ids in
example/app.json and PLACEMENT_ID in App.tsx, then:
cd example
pnpm install
pnpm ios # or pnpm androidLicense
MIT
