@tenqube/visual-reward-react-native 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +690 -0
- package/android/build.gradle +47 -0
- package/android/consumer-rules.pro +9 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/tenqube/visualreward/VisualRewardModule.kt +223 -0
- package/android/src/main/java/com/tenqube/visualreward/VisualRewardPackage.kt +14 -0
- package/index.ts +8 -0
- package/ios/VisualReward.m +12 -0
- package/ios/VisualReward.swift +135 -0
- package/package.json +52 -0
- package/src/ConfigCache.ts +45 -0
- package/src/NativeVisualReward.ts +52 -0
- package/src/RewardWebView.tsx +552 -0
- package/src/VisualReward.ts +190 -0
- package/src/VisualRewardContainer.tsx +121 -0
- package/src/types.ts +66 -0
- package/src/utils.ts +24 -0
- package/visual-reward-react-native.podspec +20 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.kotlin_version = ext.has("kotlinVersion") ? ext.kotlinVersion : "1.9.24"
|
|
3
|
+
repositories {
|
|
4
|
+
google()
|
|
5
|
+
mavenCentral()
|
|
6
|
+
}
|
|
7
|
+
dependencies {
|
|
8
|
+
classpath "com.android.tools.build:gradle:8.1.1"
|
|
9
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
apply plugin: "com.android.library"
|
|
14
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
15
|
+
|
|
16
|
+
def safeExtGet(prop, fallback) {
|
|
17
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
android {
|
|
21
|
+
namespace "com.tenqube.visualreward"
|
|
22
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
|
23
|
+
|
|
24
|
+
defaultConfig {
|
|
25
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
26
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
|
27
|
+
// GMA / AdPopcorn 리플렉션 대상 보존 규칙을 호스트 앱 R8 에 자동 전파.
|
|
28
|
+
consumerProguardFiles "consumer-rules.pro"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
compileOptions {
|
|
32
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
33
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
repositories {
|
|
38
|
+
google()
|
|
39
|
+
mavenCentral()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
dependencies {
|
|
43
|
+
// React Native (autolink 이 버전 주입)
|
|
44
|
+
implementation "com.facebook.react:react-native:+"
|
|
45
|
+
// GMA / AdPopcorn / react-native-webview 는 리플렉션·뷰탐색으로만 접근하므로
|
|
46
|
+
// 컴파일 의존성 없음(호스트 앱이 각 SDK 를 링크하면 리플렉션이 동작, 없으면 no-op).
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# AdPopcorn SSP 브릿지 (Class.forName 문자열 리플렉션으로 참조 — R8가 못 봄)
|
|
2
|
+
# 호스트 앱에 AdPopcorn SDK가 없으면 이 룰은 무시됨
|
|
3
|
+
-keep class com.igaworks.ssp.part.hybrid.AdPopcornSSPJsBridge { *; }
|
|
4
|
+
|
|
5
|
+
# AdMob WebView API (Class.forName 문자열 리플렉션으로 참조 — R8가 못 봄)
|
|
6
|
+
# 호스트 앱에 Google Mobile Ads SDK가 없으면 이 룰은 무시됨
|
|
7
|
+
# Next-Gen(libraries.ads.mobile.sdk) 우선, 레거시(gms.ads) 폴백 — 둘 다 keep
|
|
8
|
+
-keep class com.google.android.libraries.ads.mobile.sdk.MobileAds { *; }
|
|
9
|
+
-keep class com.google.android.gms.ads.MobileAds { *; }
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
package com.tenqube.visualreward
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.ActivityNotFoundException
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import android.view.View
|
|
10
|
+
import android.view.ViewGroup
|
|
11
|
+
import android.webkit.WebView
|
|
12
|
+
import com.facebook.react.bridge.Promise
|
|
13
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
14
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
15
|
+
import com.facebook.react.bridge.ReactMethod
|
|
16
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tenqube Visual Reward — React Native 네이티브 모듈 (Android).
|
|
20
|
+
*
|
|
21
|
+
* Kotlin/Swift/Flutter SDK 와 1:1 로, 리워드 웹뷰를 GMA(Google Mobile Ads) 에 등록하고
|
|
22
|
+
* AdPopcorn SSP JS 브릿지를 주입한다. react-native-webview 의 내부 WebView 인스턴스를
|
|
23
|
+
* 노출하지 않으므로, Flutter 플러그인과 동일하게 뷰 계층에서 WebView 를 탐색한다.
|
|
24
|
+
*
|
|
25
|
+
* GMA / AdPopcorn 은 리플렉션으로만 참조한다 — 호스트 앱에 각 SDK 가 링크돼 있으면 동작하고,
|
|
26
|
+
* 없으면 조용히 no-op 이다.
|
|
27
|
+
*/
|
|
28
|
+
class VisualRewardModule(private val reactContext: ReactApplicationContext) :
|
|
29
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
30
|
+
|
|
31
|
+
override fun getName(): String = "VisualReward"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 리워드 웹뷰에 GMA registerWebView 등록 + AdPopcorn 네이티브 브릿지 주입을 수행한다(best-effort).
|
|
35
|
+
*
|
|
36
|
+
* JS 는 실제 오퍼월 URL 로드 "전"(about:blank 로드 완료 시점)에 호출하고 완료를 await 한다.
|
|
37
|
+
* Android 의 addJavascriptInterface 는 "다음 로드"부터 적용되므로, about:blank 에서 주입한 뒤
|
|
38
|
+
* 실제 URL 을 로드하면 리로드 없이 브릿지가 바인딩된다.
|
|
39
|
+
*/
|
|
40
|
+
@ReactMethod
|
|
41
|
+
fun attachAdBridges(promise: Promise) {
|
|
42
|
+
UiThreadUtil.runOnUiThread {
|
|
43
|
+
try {
|
|
44
|
+
val webView = findRewardWebView()
|
|
45
|
+
if (webView == null) {
|
|
46
|
+
Log.w(TAG, "attachAdBridges skipped (WebView 미검출)")
|
|
47
|
+
promise.resolve(false)
|
|
48
|
+
return@runOnUiThread
|
|
49
|
+
}
|
|
50
|
+
registerAdMobWebView(webView)
|
|
51
|
+
injectAdPopcornBridge(webView)
|
|
52
|
+
applyLightStatusBar(webView)
|
|
53
|
+
promise.resolve(true)
|
|
54
|
+
} catch (e: Exception) {
|
|
55
|
+
Log.e(TAG, "attachAdBridges failed: ${e.javaClass.simpleName}", e)
|
|
56
|
+
promise.resolve(false)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Android intent:// / market:// 스킴을 네이티브에서 처리한다.
|
|
63
|
+
* Kotlin SDK RewardWebViewActivity.tryHandleSpecialSchemes 와 동일.
|
|
64
|
+
* 항상 handled 여부(boolean)를 resolve 한다.
|
|
65
|
+
*/
|
|
66
|
+
@ReactMethod
|
|
67
|
+
fun handleIntentScheme(url: String, promise: Promise) {
|
|
68
|
+
try {
|
|
69
|
+
val uri = Uri.parse(url)
|
|
70
|
+
when (uri.scheme?.lowercase()) {
|
|
71
|
+
"intent" -> {
|
|
72
|
+
try {
|
|
73
|
+
val parsedIntent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
|
|
74
|
+
val pkg = parsedIntent.`package`
|
|
75
|
+
val fallbackUrl = parsedIntent.getStringExtra("browser_fallback_url")
|
|
76
|
+
if (!pkg.isNullOrEmpty()) {
|
|
77
|
+
try {
|
|
78
|
+
startExternal(parsedIntent)
|
|
79
|
+
} catch (_: ActivityNotFoundException) {
|
|
80
|
+
if (!fallbackUrl.isNullOrEmpty()) openExternally(Uri.parse(fallbackUrl))
|
|
81
|
+
else openExternally(Uri.parse("market://details?id=$pkg"))
|
|
82
|
+
}
|
|
83
|
+
} else if (!fallbackUrl.isNullOrEmpty()) {
|
|
84
|
+
openExternally(Uri.parse(fallbackUrl))
|
|
85
|
+
}
|
|
86
|
+
} catch (_: Exception) {
|
|
87
|
+
}
|
|
88
|
+
promise.resolve(true)
|
|
89
|
+
}
|
|
90
|
+
"market" -> {
|
|
91
|
+
openExternally(uri)
|
|
92
|
+
promise.resolve(true)
|
|
93
|
+
}
|
|
94
|
+
else -> promise.resolve(false)
|
|
95
|
+
}
|
|
96
|
+
} catch (_: Exception) {
|
|
97
|
+
promise.resolve(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// MARK: - GMA registerWebView (리플렉션)
|
|
102
|
+
// Kotlin SDK RewardWebViewActivity.onCreate 의 등록 로직과 동일.
|
|
103
|
+
|
|
104
|
+
private fun registerAdMobWebView(webView: WebView) {
|
|
105
|
+
val mobileAdsClassNames = listOf(
|
|
106
|
+
"com.google.android.libraries.ads.mobile.sdk.MobileAds", // Next-Gen
|
|
107
|
+
"com.google.android.gms.ads.MobileAds", // 레거시
|
|
108
|
+
)
|
|
109
|
+
var adMobRegistered = false
|
|
110
|
+
var adMobClassFound = false
|
|
111
|
+
for (className in mobileAdsClassNames) {
|
|
112
|
+
val clazz = try {
|
|
113
|
+
Class.forName(className)
|
|
114
|
+
} catch (e: ClassNotFoundException) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
adMobClassFound = true
|
|
118
|
+
try {
|
|
119
|
+
clazz.getMethod("registerWebView", WebView::class.java).invoke(null, webView)
|
|
120
|
+
Log.i(TAG, "AdMob registerWebView registered ($className)")
|
|
121
|
+
adMobRegistered = true
|
|
122
|
+
break
|
|
123
|
+
} catch (e: NoSuchMethodException) {
|
|
124
|
+
// 시그니처 불일치 — 다음 후보 시도
|
|
125
|
+
} catch (e: Exception) {
|
|
126
|
+
Log.e(TAG, "AdMob registerWebView failed ($className): ${e.javaClass.simpleName}", e)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!adMobRegistered) {
|
|
130
|
+
if (adMobClassFound) {
|
|
131
|
+
Log.w(TAG, "Google Mobile Ads SDK present but registerWebView failed — AdMob WebView API not registered")
|
|
132
|
+
} else {
|
|
133
|
+
Log.w(TAG, "Google Mobile Ads SDK not on classpath — AdMob WebView API not registered")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// MARK: - AdPopcorn SSP 브릿지 주입 (리플렉션)
|
|
139
|
+
// MARK: - AdPopcorn SSP 네이티브 브릿지 주입 (리플렉션)
|
|
140
|
+
// AdPopcorn 의 실제 네이티브 브릿지 AdPopcornSSPJsBridge(Context, WebView) 를 주입한다.
|
|
141
|
+
// 이 브릿지 생성자가 네이티브 SSP SDK 에 웹뷰를 등록해, 웹 배너 SDK(AdPopcornSSPWebSDK)가
|
|
142
|
+
// 네이티브 컨텍스트로 광고를 서빙할 수 있게 한다. (JS shim 으로는 이 네이티브 등록을 대체할 수 없음)
|
|
143
|
+
|
|
144
|
+
private fun injectAdPopcornBridge(webView: WebView) {
|
|
145
|
+
try {
|
|
146
|
+
val bridgeClass = Class.forName("com.igaworks.ssp.part.hybrid.AdPopcornSSPJsBridge")
|
|
147
|
+
val constructor = bridgeClass.getConstructor(Context::class.java, WebView::class.java)
|
|
148
|
+
val context: Context = reactApplicationContext.currentActivity ?: reactApplicationContext
|
|
149
|
+
val bridge = constructor.newInstance(context, webView)
|
|
150
|
+
webView.addJavascriptInterface(bridge, "AdPopcornSSPJsBridge")
|
|
151
|
+
Log.i(TAG, "AdPopcornSSPJsBridge registered")
|
|
152
|
+
} catch (e: ClassNotFoundException) {
|
|
153
|
+
Log.w(TAG, "AdPopcornSSPJsBridge class not on classpath — bridge not registered")
|
|
154
|
+
} catch (e: Exception) {
|
|
155
|
+
Log.e(TAG, "AdPopcornSSPJsBridge registration failed: ${e.javaClass.simpleName}", e)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// MARK: - 상태바 아이콘 색상 (Dialog window)
|
|
160
|
+
// 리워드는 RN Modal(별도 Dialog window)로 뜬다. RN StatusBar API 는 Activity window 만
|
|
161
|
+
// 제어하므로 Modal 의 상태바 아이콘 색이 바뀌지 않는다(흰 배경에 흰 아이콘 → 안 보임).
|
|
162
|
+
// 리워드 WebView 는 Dialog window 에 붙어 있으므로, 그 뷰에서 WindowInsetsController 를 얻어
|
|
163
|
+
// 해당 Dialog window 의 상태바 아이콘을 dark(검정)로 설정한다. 상단 배경은 흰색(safe-area).
|
|
164
|
+
|
|
165
|
+
private fun applyLightStatusBar(webView: WebView) {
|
|
166
|
+
try {
|
|
167
|
+
val controller = androidx.core.view.ViewCompat.getWindowInsetsController(webView)
|
|
168
|
+
if (controller != null) {
|
|
169
|
+
controller.isAppearanceLightStatusBars = true
|
|
170
|
+
Log.i(TAG, "status bar set to light appearance (dark icons)")
|
|
171
|
+
} else {
|
|
172
|
+
Log.w(TAG, "WindowInsetsController 미검출 — 상태바 아이콘 색 미설정")
|
|
173
|
+
}
|
|
174
|
+
} catch (e: Exception) {
|
|
175
|
+
Log.e(TAG, "applyLightStatusBar failed: ${e.javaClass.simpleName}", e)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// MARK: - 뷰 계층에서 리워드 WebView 탐색
|
|
180
|
+
// 화면에 보이는 가장 큰 WebView 를 리워드 웹뷰로 간주한다.
|
|
181
|
+
// (react-native-webview 의 RNCWebView 는 android.webkit.WebView 를 상속)
|
|
182
|
+
|
|
183
|
+
private fun findRewardWebView(): WebView? {
|
|
184
|
+
val activity: Activity = reactApplicationContext.currentActivity ?: return null
|
|
185
|
+
val root = activity.window?.decorView?.rootView ?: return null
|
|
186
|
+
val webViews = ArrayList<WebView>()
|
|
187
|
+
collectWebViews(root, webViews)
|
|
188
|
+
return webViews
|
|
189
|
+
.filter { it.isShown }
|
|
190
|
+
.maxByOrNull { it.width.toLong() * it.height.toLong() }
|
|
191
|
+
?: webViews.firstOrNull()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun collectWebViews(view: View, out: MutableList<WebView>) {
|
|
195
|
+
if (view is WebView) out.add(view)
|
|
196
|
+
if (view is ViewGroup) {
|
|
197
|
+
for (i in 0 until view.childCount) collectWebViews(view.getChildAt(i), out)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// MARK: - 외부 실행 헬퍼
|
|
202
|
+
|
|
203
|
+
private fun startExternal(intent: Intent) {
|
|
204
|
+
val activity = reactApplicationContext.currentActivity
|
|
205
|
+
if (activity != null) {
|
|
206
|
+
activity.startActivity(intent)
|
|
207
|
+
} else {
|
|
208
|
+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
209
|
+
reactApplicationContext.applicationContext.startActivity(intent)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private fun openExternally(uri: Uri) {
|
|
214
|
+
try {
|
|
215
|
+
startExternal(Intent(Intent.ACTION_VIEW, uri))
|
|
216
|
+
} catch (_: ActivityNotFoundException) {
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
companion object {
|
|
221
|
+
private const val TAG = "VisualReward"
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package com.tenqube.visualreward
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
class VisualRewardPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
10
|
+
listOf(VisualRewardModule(reactContext))
|
|
11
|
+
|
|
12
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
13
|
+
emptyList()
|
|
14
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { VisualReward } from "./src/VisualReward";
|
|
2
|
+
export { default as VisualRewardContainer } from "./src/VisualRewardContainer";
|
|
3
|
+
export { VisualRewardError } from "./src/types";
|
|
4
|
+
export type {
|
|
5
|
+
VisualRewardErrorCode,
|
|
6
|
+
VisualRewardInitializeOptions,
|
|
7
|
+
VisualRewardOpenOptions,
|
|
8
|
+
} from "./src/types";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
|
|
3
|
+
// Swift 클래스 VisualReward 를 React Native 브릿지에 노출한다.
|
|
4
|
+
@interface RCT_EXTERN_MODULE(VisualReward, NSObject)
|
|
5
|
+
|
|
6
|
+
RCT_EXTERN_METHOD(attachAdBridges:(RCTPromiseResolveBlock)resolve
|
|
7
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
8
|
+
|
|
9
|
+
RCT_EXTERN_METHOD(hasTrackingDescription:(RCTPromiseResolveBlock)resolve
|
|
10
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
11
|
+
|
|
12
|
+
@end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
import UIKit
|
|
4
|
+
import WebKit
|
|
5
|
+
|
|
6
|
+
// Tenqube Visual Reward — React Native 네이티브 모듈 (iOS).
|
|
7
|
+
//
|
|
8
|
+
// Swift/Kotlin/Flutter SDK 와 1:1 로, 리워드 웹뷰를 GMA(Google Mobile Ads) 에 등록하고
|
|
9
|
+
// AdPopcorn SSP JS 브릿지를 주입한다. react-native-webview 는 내부 WKWebView 인스턴스를
|
|
10
|
+
// 노출하지 않으므로, Flutter 플러그인과 동일하게 뷰 계층에서 WKWebView 를 탐색한다.
|
|
11
|
+
//
|
|
12
|
+
// GMA / AdPopcorn 은 리플렉션으로만 참조한다 — 호스트 앱에 각 SDK 가 링크돼 있으면 동작하고,
|
|
13
|
+
// 없으면 조용히 no-op 이다.
|
|
14
|
+
@objc(VisualReward)
|
|
15
|
+
class VisualReward: NSObject {
|
|
16
|
+
|
|
17
|
+
@objc static func requiresMainQueueSetup() -> Bool {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// MARK: - JS 에 노출되는 메서드
|
|
22
|
+
|
|
23
|
+
/// 리워드 웹뷰에 GMA registerWebView 등록 + AdPopcorn 네이티브 브릿지 주입을 수행한다(best-effort).
|
|
24
|
+
/// JS 는 실제 오퍼월 URL 로드 전(about:blank 로드 완료 시점)에 호출하고 완료를 await 한다.
|
|
25
|
+
@objc(attachAdBridges:rejecter:)
|
|
26
|
+
func attachAdBridges(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
27
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
28
|
+
DispatchQueue.main.async {
|
|
29
|
+
guard let webView = VisualReward.findRewardWebView() else {
|
|
30
|
+
NSLog("VisualReward: attachAdBridges skipped (WKWebView 미검출)")
|
|
31
|
+
resolve(false)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
VisualReward.registerAdMobWebView(webView)
|
|
35
|
+
VisualReward.injectAdPopcornBridge(webView)
|
|
36
|
+
resolve(true)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Info.plist 에 NSUserTrackingUsageDescription 키가 있는지 반환한다.
|
|
41
|
+
/// (Swift SDK 의 ATT 요청 게이팅과 동일)
|
|
42
|
+
@objc(hasTrackingDescription:rejecter:)
|
|
43
|
+
func hasTrackingDescription(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
44
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
45
|
+
let has = Bundle.main.object(forInfoDictionaryKey: "NSUserTrackingUsageDescription") != nil
|
|
46
|
+
resolve(has)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - GMA registerWebView (리플렉션)
|
|
50
|
+
// Swift SDK RewardWebViewController.setupLayout 의 GADMobileAds.registerWebView 와 동일.
|
|
51
|
+
|
|
52
|
+
private static func registerAdMobWebView(_ webView: WKWebView) {
|
|
53
|
+
guard let gadClass = NSClassFromString("GADMobileAds") else {
|
|
54
|
+
NSLog("VisualReward: Google Mobile Ads SDK not linked — AdMob WebView API not registered")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
let sharedSel = NSSelectorFromString("sharedInstance")
|
|
58
|
+
let registerSel = NSSelectorFromString("registerWebView:")
|
|
59
|
+
if (gadClass as AnyObject).responds(to: sharedSel),
|
|
60
|
+
let shared = (gadClass as AnyObject).perform(sharedSel)?.takeUnretainedValue() as? NSObject,
|
|
61
|
+
shared.responds(to: registerSel) {
|
|
62
|
+
shared.perform(registerSel, with: webView)
|
|
63
|
+
NSLog("VisualReward: AdMob registerWebView registered")
|
|
64
|
+
} else {
|
|
65
|
+
NSLog("VisualReward: GADMobileAds present but registerWebView unavailable")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MARK: - AdPopcorn SSP 네이티브 브릿지 주입 (리플렉션)
|
|
70
|
+
// Swift SDK 의 AdPopcornSSPWKScriptMessageHandler 를 리플렉션 생성해 대상 WKWebView 의
|
|
71
|
+
// userContentController 에 "AdPopcornSSPJsBridge" 이름으로 추가한다. 이 네이티브 핸들러가
|
|
72
|
+
// 네이티브 SSP 컨텍스트를 제공해, 웹 배너 SDK(AdPopcornSSPWebSDK)가 광고를 서빙할 수 있게 한다.
|
|
73
|
+
|
|
74
|
+
private static func injectAdPopcornBridge(_ webView: WKWebView) {
|
|
75
|
+
let ucc = webView.configuration.userContentController
|
|
76
|
+
ucc.removeScriptMessageHandler(forName: "AdPopcornSSPJsBridge")
|
|
77
|
+
|
|
78
|
+
guard let handlerClass = NSClassFromString("AdPopcornSSPWKScriptMessageHandler") as? NSObject.Type,
|
|
79
|
+
let handler = handlerClass.init() as? (WKScriptMessageHandler & NSObject) else {
|
|
80
|
+
NSLog("VisualReward: AdPopcornSSPWKScriptMessageHandler not linked — bridge not registered")
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if let vc = webView.rewardOwningViewController() {
|
|
84
|
+
handler.setValue(vc, forKey: "viewController")
|
|
85
|
+
}
|
|
86
|
+
handler.setValue(webView, forKey: "webView")
|
|
87
|
+
ucc.add(handler, name: "AdPopcornSSPJsBridge")
|
|
88
|
+
objc_setAssociatedObject(webView, &VisualReward.adPopcornHandlerKey, handler, .OBJC_ASSOCIATION_RETAIN)
|
|
89
|
+
NSLog("VisualReward: AdPopcornSSPJsBridge registered")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private static var adPopcornHandlerKey: UInt8 = 0
|
|
93
|
+
|
|
94
|
+
// MARK: - 뷰 계층에서 리워드 WKWebView 탐색
|
|
95
|
+
// Flutter 플러그인과 동일: key window 하위의 WKWebView 중 화면에 보이는 가장 큰 것을
|
|
96
|
+
// 리워드 웹뷰로 간주한다. (react-native-webview 의 RNCWebView 는 WKWebView 를 subview 로 가짐)
|
|
97
|
+
|
|
98
|
+
private static func findRewardWebView() -> WKWebView? {
|
|
99
|
+
guard let window = keyWindow() else { return nil }
|
|
100
|
+
var webViews: [WKWebView] = []
|
|
101
|
+
collectWebViews(window, into: &webViews)
|
|
102
|
+
return webViews
|
|
103
|
+
.filter { $0.window != nil }
|
|
104
|
+
.max(by: { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height })
|
|
105
|
+
?? webViews.first
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private static func keyWindow() -> UIWindow? {
|
|
109
|
+
let sceneWindow = UIApplication.shared.connectedScenes
|
|
110
|
+
.compactMap { $0 as? UIWindowScene }
|
|
111
|
+
.flatMap { $0.windows }
|
|
112
|
+
.first { $0.isKeyWindow }
|
|
113
|
+
if let sceneWindow = sceneWindow { return sceneWindow }
|
|
114
|
+
return UIApplication.shared.windows.first { $0.isKeyWindow }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private static func collectWebViews(_ view: UIView, into out: inout [WKWebView]) {
|
|
118
|
+
if let webView = view as? WKWebView { out.append(webView) }
|
|
119
|
+
for subview in view.subviews {
|
|
120
|
+
collectWebViews(subview, into: &out)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private extension UIView {
|
|
126
|
+
/// 이 뷰를 소유한 UIViewController 를 responder chain 에서 찾는다. (AdPopcorn 핸들러 viewController KVC 용)
|
|
127
|
+
func rewardOwningViewController() -> UIViewController? {
|
|
128
|
+
var responder: UIResponder? = self
|
|
129
|
+
while let r = responder {
|
|
130
|
+
if let vc = r as? UIViewController { return vc }
|
|
131
|
+
responder = r.next
|
|
132
|
+
}
|
|
133
|
+
return nil
|
|
134
|
+
}
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tenqube/visual-reward-react-native",
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "Tenqube Visual Reward SDK for React Native",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"types": "index.ts",
|
|
7
|
+
"react-native": "index.ts",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/tenqube/visual-reward-sdk.git",
|
|
14
|
+
"directory": "react-native"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.ts",
|
|
18
|
+
"src",
|
|
19
|
+
"android",
|
|
20
|
+
"ios",
|
|
21
|
+
"visual-reward-react-native.podspec",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@react-native-community/netinfo": "^11.0.0",
|
|
27
|
+
"react-native-idfa-aaid": "^0.1.1",
|
|
28
|
+
"react-native-device-info": "^10.0.0",
|
|
29
|
+
"react-native-orientation-locker": "^1.7.0",
|
|
30
|
+
"react-native-permissions": "^4.0.0",
|
|
31
|
+
"react-native-webview": "^13.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": ">=18.0.0",
|
|
35
|
+
"react-native": ">=0.72.0",
|
|
36
|
+
"react-native-safe-area-context": ">=4.0.0",
|
|
37
|
+
"react-native-google-mobile-ads": "*",
|
|
38
|
+
"react-native-adpopcorn-ssp": "*"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react-native-google-mobile-ads": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"react-native-adpopcorn-ssp": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/react": "^19.0.0",
|
|
50
|
+
"typescript": "5"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RemoteConfig } from './types';
|
|
2
|
+
|
|
3
|
+
const TTL_MS = 3600 * 1000;
|
|
4
|
+
const KEY_PREFIX = 'visual_reward_config_';
|
|
5
|
+
|
|
6
|
+
interface CacheEntry {
|
|
7
|
+
config: RemoteConfig;
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let _storage: AsyncStorageLike | null = null;
|
|
12
|
+
|
|
13
|
+
export interface AsyncStorageLike {
|
|
14
|
+
getItem(key: string): Promise<string | null>;
|
|
15
|
+
setItem(key: string, value: string): Promise<void>;
|
|
16
|
+
removeItem(key: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setStorage(storage: AsyncStorageLike): void {
|
|
20
|
+
_storage = storage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readCache(appKey: string): Promise<RemoteConfig | null> {
|
|
24
|
+
if (!_storage) return null;
|
|
25
|
+
try {
|
|
26
|
+
const raw = await _storage.getItem(KEY_PREFIX + appKey);
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
const entry: CacheEntry = JSON.parse(raw);
|
|
29
|
+
if (Date.now() > entry.expiresAt) {
|
|
30
|
+
await _storage.removeItem(KEY_PREFIX + appKey);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return entry.config;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeCache(appKey: string, config: RemoteConfig): Promise<void> {
|
|
40
|
+
if (!_storage) return;
|
|
41
|
+
try {
|
|
42
|
+
const entry: CacheEntry = { config, expiresAt: Date.now() + TTL_MS };
|
|
43
|
+
await _storage.setItem(KEY_PREFIX + appKey, JSON.stringify(entry));
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NativeModules, Platform } from "react-native";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 네이티브 모듈(iOS Swift / Android Kotlin) 브릿지.
|
|
5
|
+
*
|
|
6
|
+
* - `attachAdBridges`: 리워드 웹뷰에 GMA registerWebView 등록 + AdPopcorn SSP 브릿지 주입(best-effort).
|
|
7
|
+
* - `hasTrackingDescription`(iOS): Info.plist 의 NSUserTrackingUsageDescription 존재 여부.
|
|
8
|
+
* - `handleIntentScheme`(Android): intent:// / market:// 스킴 네이티브 처리.
|
|
9
|
+
*
|
|
10
|
+
* 네이티브 모듈이 링크되지 않은 환경(예: 자동링크 누락)에서도 앱이 죽지 않도록 모든 호출을 방어한다.
|
|
11
|
+
*/
|
|
12
|
+
interface VisualRewardNativeModule {
|
|
13
|
+
attachAdBridges(): Promise<boolean>;
|
|
14
|
+
hasTrackingDescription(): Promise<boolean>;
|
|
15
|
+
handleIntentScheme(url: string): Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const NativeModule: Partial<VisualRewardNativeModule> | undefined =
|
|
19
|
+
NativeModules.VisualReward;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 리워드 웹뷰에 GMA registerWebView + AdPopcorn 네이티브 브릿지를 연결한다.
|
|
23
|
+
* 실제 오퍼월 URL 로드 "전"(about:blank 로드 완료 시점)에 호출하고 완료를 await 한다.
|
|
24
|
+
* (Android addJavascriptInterface 는 다음 로드부터 적용되므로 순서가 중요)
|
|
25
|
+
*/
|
|
26
|
+
export async function attachAdBridges(): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
return (await NativeModule?.attachAdBridges?.()) ?? false;
|
|
29
|
+
} catch (_) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** iOS: Info.plist 에 NSUserTrackingUsageDescription 키가 있는지. (Android/미링크 → false) */
|
|
35
|
+
export async function hasTrackingDescription(): Promise<boolean> {
|
|
36
|
+
if (Platform.OS !== "ios") return false;
|
|
37
|
+
try {
|
|
38
|
+
return (await NativeModule?.hasTrackingDescription?.()) ?? false;
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Android: intent:// / market:// 를 네이티브에서 처리. 처리 여부(handled)를 반환. (iOS/미링크 → false) */
|
|
45
|
+
export async function handleIntentScheme(url: string): Promise<boolean> {
|
|
46
|
+
if (Platform.OS !== "android") return false;
|
|
47
|
+
try {
|
|
48
|
+
return (await NativeModule?.handleIntentScheme?.(url)) ?? false;
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|