@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.
@@ -0,0 +1,552 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import {
9
+ AppState,
10
+ AppStateStatus,
11
+ Linking,
12
+ Platform,
13
+ StyleSheet,
14
+ View,
15
+ } from "react-native";
16
+ import WebView, { WebViewMessageEvent } from "react-native-webview";
17
+ import type { WebViewNavigation } from "react-native-webview/lib/WebViewTypes";
18
+ import DeviceInfo from "react-native-device-info";
19
+ import AdvertisingIdAaid from "react-native-idfa-aaid";
20
+ import NetInfo from "@react-native-community/netinfo";
21
+ import Orientation from "react-native-orientation-locker";
22
+
23
+ import type { RewardWebViewHandle, RewardWebViewProps } from "./types";
24
+ import { isAllowedDomain, normalizeUrl } from "./utils";
25
+ import {
26
+ attachAdBridges,
27
+ handleIntentScheme,
28
+ hasTrackingDescription,
29
+ } from "./NativeVisualReward";
30
+
31
+ // ─── ATT 요청 (iOS) ──────────────────────────────────────────────────────────
32
+ // react-native-permissions로 ATT 권한 요청.
33
+ // NSUserTrackingUsageDescription이 없으면 try/catch로 스킵.
34
+ let requestTrackingPermission: (() => Promise<void>) | null = null;
35
+ if (Platform.OS === "ios") {
36
+ try {
37
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
38
+ const { request, PERMISSIONS } = require("react-native-permissions") as {
39
+ request: (permission: string) => Promise<string>;
40
+ PERMISSIONS: { IOS: { APP_TRACKING_TRANSPARENCY: string } };
41
+ };
42
+ requestTrackingPermission = async () => {
43
+ await request(PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY);
44
+ };
45
+ } catch (_) {
46
+ requestTrackingPermission = null;
47
+ }
48
+ }
49
+
50
+ // ─── 폴리필 스크립트 ──────────────────────────────────────────────────────────
51
+ /**
52
+ * Android / iOS 양쪽 호출 방식을 통합하는 폴리필을 웹뷰에 주입합니다.
53
+ *
54
+ * Android 방식:
55
+ * window.nativeBridge.openExternalBrowser(url) ← url 직접 문자열
56
+ * window.nativeBridge.pushUrl(JSON.stringify({url,replace}))
57
+ * window.nativeBridge.<fncName>(JSON.stringify(params))
58
+ *
59
+ * iOS 방식:
60
+ * window.webkit.messageHandlers.nativeBridge.postMessage(JSON.stringify({fncName, params}))
61
+ *
62
+ * 통합 후 내부적으로 ReactNativeWebView.postMessage({fncName, params}) 로 전달.
63
+ */
64
+ const POLYFILL_SCRIPT = `
65
+ (function() {
66
+ if (window.__rnBridgeInjected) return;
67
+ window.__rnBridgeInjected = true;
68
+
69
+ function _post(fncName, params) {
70
+ window.ReactNativeWebView.postMessage(JSON.stringify({ fncName: fncName, params: params || {} }));
71
+ }
72
+
73
+ // ── Android 방식: window.nativeBridge.xxx(...) ──
74
+ var _androidHandlers = [
75
+ 'getAppVersion','getADID','getIDFA','getIDFV',
76
+ 'getDeviceInfo','registerBackKeyListener','unregisterBackKeyListener',
77
+ 'swipeBackEnabled','finish'
78
+ ];
79
+ var nativeBridgeObj = {};
80
+ _androidHandlers.forEach(function(name) {
81
+ nativeBridgeObj[name] = function(arg) {
82
+ var params = {};
83
+ if (typeof arg === 'string') {
84
+ try { params = JSON.parse(arg); } catch(_) { params = { value: arg }; }
85
+ } else if (arg && typeof arg === 'object') {
86
+ params = arg;
87
+ }
88
+ _post(name, params);
89
+ };
90
+ });
91
+ nativeBridgeObj['openTrackingSettings'] = function() {
92
+ _post('openTrackingSettings', {});
93
+ };
94
+ // openExternalBrowser: url이 직접 문자열 파라미터
95
+ nativeBridgeObj['openExternalBrowser'] = function(url) {
96
+ _post('openExternalBrowser', { url: url });
97
+ };
98
+ // pushUrl: JSON 문자열 파라미터
99
+ nativeBridgeObj['pushUrl'] = function(jsonStr) {
100
+ var params = {};
101
+ try { params = JSON.parse(jsonStr); } catch(_) {}
102
+ _post('pushUrl', params);
103
+ };
104
+ window.nativeBridge = nativeBridgeObj;
105
+
106
+ // ── iOS 방식: window.webkit.messageHandlers.nativeBridge.postMessage(...) ──
107
+ if (!window.webkit) window.webkit = {};
108
+ if (!window.webkit.messageHandlers) window.webkit.messageHandlers = {};
109
+ window.webkit.messageHandlers.nativeBridge = {
110
+ postMessage: function(jsonStr) {
111
+ var obj = {};
112
+ try { obj = JSON.parse(jsonStr); } catch(_) {}
113
+ _post(obj.fncName, obj.params);
114
+ }
115
+ };
116
+
117
+ // ── nativeCallback 객체 초기화 ──
118
+ if (!window.nativeCallback) window.nativeCallback = {};
119
+ })();
120
+ true;
121
+ `;
122
+
123
+ // ─── 컴포넌트 ─────────────────────────────────────────────────────────────────
124
+
125
+ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
126
+ function RewardWebView(
127
+ {
128
+ url,
129
+ allowedDomains,
130
+ debugEnabled = false,
131
+ onClose,
132
+ },
133
+ ref,
134
+ ) {
135
+ const webViewRef = useRef<WebView>(null);
136
+ const backKeyListenerRegisteredRef = useRef(false);
137
+ const canGoBackRef = useRef(false);
138
+ const [swipeBackEnabled, setSwipeBackEnabled] = useState(true);
139
+ // 로드 단계: (Android 전용) 먼저 about:blank 를 띄운 뒤 네이티브 브릿지(AdPopcorn/GMA)를
140
+ // 주입하고, 그 다음 실제 오퍼월 URL 을 로드한다. (Android addJavascriptInterface 가
141
+ // "다음 로드"부터 적용되는 특성 때문 — 리로드 없이 브릿지가 실제 페이지에 바인딩되게 한다.)
142
+ //
143
+ // iOS 는 about:blank 를 쓰지 않는다: react-native-webview(iOS) 는 host 가 없는 URL 을
144
+ // loadRequest 가 아닌 loadFileURL: 로 라우팅하는데, about:blank 은 file URL 이 아니라서
145
+ // NSInvalidArgumentException(SIGABRT) 로 앱이 죽는다. iOS 는 실제 URL 을 바로 로드하고
146
+ // 로드 완료(onLoadEnd) 후 WK 스크립트 핸들러(AdPopcorn)/GMA 를 부착한다.
147
+ const [loadPhase, setLoadPhase] = useState<"blank" | "real">(
148
+ Platform.OS === "android" ? "blank" : "real",
149
+ );
150
+ const attachStartedRef = useRef(false);
151
+
152
+ // 허용 도메인: url의 host도 자동 포함
153
+ const effectiveDomains = React.useMemo(() => {
154
+ const domains = allowedDomains.map((d) => d.toLowerCase());
155
+ try {
156
+ const host = new URL(normalizeUrl(url)).hostname;
157
+ if (
158
+ host &&
159
+ !domains.some((d) => host === d || host.endsWith(`.${d}`))
160
+ ) {
161
+ domains.push(host.toLowerCase());
162
+ }
163
+ } catch (_) {}
164
+ return domains;
165
+ }, [allowedDomains, url]);
166
+
167
+ // ── 세로 모드 고정 ──────────────────────────────────────────────────────────
168
+ useEffect(() => {
169
+ Orientation.lockToPortrait();
170
+ return () => {
171
+ Orientation.unlockAllOrientations();
172
+ };
173
+ }, []);
174
+
175
+ // ── 백그라운드 진입 시 미디어 중단 ─────────────────────────────────────────
176
+ useEffect(() => {
177
+ const handleAppStateChange = (nextState: AppStateStatus) => {
178
+ if (nextState === "background" || nextState === "inactive") {
179
+ // JS로 video/audio 일시정지
180
+ webViewRef.current?.injectJavaScript(
181
+ `(function(){
182
+ var els=document.querySelectorAll('video,audio');
183
+ for(var i=0;i<els.length;i++){try{els[i].pause();}catch(_){}}
184
+ })(); true;`,
185
+ );
186
+ }
187
+ };
188
+ const sub = AppState.addEventListener("change", handleAppStateChange);
189
+ return () => sub.remove();
190
+ }, []);
191
+
192
+ // ── Back 키 처리 (VisualRewardContainer에서 호출) ──────────────────────────────
193
+ useImperativeHandle(
194
+ ref,
195
+ () => ({
196
+ handleBackPress: () => {
197
+ if (backKeyListenerRegisteredRef.current) {
198
+ webViewRef.current?.injectJavaScript(
199
+ `(function(){
200
+ if(window.nativeCallback&&typeof window.nativeCallback.onBackKeyPress==='function')
201
+ window.nativeCallback.onBackKeyPress();
202
+ })(); true;`,
203
+ );
204
+ return true;
205
+ }
206
+ if (canGoBackRef.current) {
207
+ webViewRef.current?.goBack();
208
+ return true;
209
+ }
210
+ return false;
211
+ },
212
+ }),
213
+ [],
214
+ );
215
+
216
+ // ── Native → JS 콜백 ────────────────────────────────────────────────────────
217
+ const callNativeCallback = useCallback(
218
+ (fnName: string, value: string, rawValue = false) => {
219
+ const jsValue = rawValue ? value : JSON.stringify(value);
220
+ const safeFnName = JSON.stringify(fnName);
221
+ webViewRef.current?.injectJavaScript(
222
+ `(function(){
223
+ var cb=window.nativeCallback&&window.nativeCallback[${safeFnName}];
224
+ if(typeof cb==='function')cb(${jsValue});
225
+ })(); true;`,
226
+ );
227
+ },
228
+ [],
229
+ );
230
+
231
+ // ── URL 정책 ────────────────────────────────────────────────────────────────
232
+ const shouldOpenExternally = useCallback(
233
+ (uri: string): boolean => {
234
+ try {
235
+ const parsed = new URL(uri);
236
+ const scheme = parsed.protocol.replace(":", "").toLowerCase();
237
+ if (scheme === "http" || scheme === "https") {
238
+ return !isAllowedDomain(parsed.hostname, effectiveDomains);
239
+ }
240
+ if (scheme === "about") return false;
241
+ return true;
242
+ } catch (_) {
243
+ return true;
244
+ }
245
+ },
246
+ [effectiveDomains],
247
+ );
248
+
249
+ const openExternally = useCallback(async (uri: string) => {
250
+ try {
251
+ // Android intent:// / market:// 은 네이티브에서 처리
252
+ // (Intent.parseUri + package 실행 + browser_fallback_url + Play Store 폴백).
253
+ if (Platform.OS === "android") {
254
+ const lower = uri.toLowerCase();
255
+ if (lower.startsWith("intent://") || lower.startsWith("market://")) {
256
+ const handled = await handleIntentScheme(uri);
257
+ if (handled) return;
258
+ }
259
+ }
260
+ await Linking.openURL(uri);
261
+ } catch (_) {}
262
+ }, []);
263
+
264
+ // ── JS 메시지 핸들러 ─────────────────────────────────────────────────────────
265
+ const handleMessage = useCallback(
266
+ async (event: WebViewMessageEvent) => {
267
+ let fncName = "";
268
+ let params: Record<string, unknown> = {};
269
+ try {
270
+ const data = JSON.parse(event.nativeEvent.data) as {
271
+ fncName: string;
272
+ params?: Record<string, unknown>;
273
+ };
274
+ fncName = data.fncName ?? "";
275
+ params = data.params ?? {};
276
+ } catch (_) {
277
+ return;
278
+ }
279
+
280
+ switch (fncName) {
281
+ // ── getAppVersion ──────────────────────────────────────────────────
282
+ case "getAppVersion": {
283
+ let version = "";
284
+ try {
285
+ version = DeviceInfo.getVersion();
286
+ } catch (_) {}
287
+ callNativeCallback("getAppVersion", version);
288
+ break;
289
+ }
290
+
291
+ // ── getADID (Android only) ─────────────────────────────────────────
292
+ case "getADID": {
293
+ let adId = "";
294
+ if (Platform.OS === "android") {
295
+ try {
296
+ const result = await AdvertisingIdAaid.getAdvertisingInfo();
297
+ const id = result.id ?? "";
298
+ if (id !== "00000000-0000-0000-0000-000000000000") adId = id;
299
+ } catch (_) {}
300
+ }
301
+ callNativeCallback("getADID", adId);
302
+ break;
303
+ }
304
+
305
+ // ── getIDFA (iOS only) ────────────────────────────────────────────
306
+ case "getIDFA": {
307
+ let idfa = "";
308
+ if (Platform.OS === "ios") {
309
+ try {
310
+ const result = await AdvertisingIdAaid.getAdvertisingInfo();
311
+ const id = result.id ?? "";
312
+ if (id !== "00000000-0000-0000-0000-000000000000") idfa = id;
313
+ } catch (_) {}
314
+ }
315
+ callNativeCallback("getIDFA", idfa);
316
+ break;
317
+ }
318
+
319
+ // ── getIDFV (iOS only) ────────────────────────────────────────────
320
+ case "getIDFV": {
321
+ let idfv = "";
322
+ if (Platform.OS === "ios") {
323
+ try {
324
+ idfv = await DeviceInfo.getUniqueId();
325
+ } catch (_) {}
326
+ }
327
+ callNativeCallback("getIDFV", idfv);
328
+ break;
329
+ }
330
+
331
+ // ── getDeviceInfo ─────────────────────────────────────────────────
332
+ case "getDeviceInfo": {
333
+ const info: Record<string, string> = {};
334
+ try {
335
+ const netState = await NetInfo.fetch();
336
+ if (netState.type === "wifi") info.network = "wifi";
337
+ else if (netState.type === "cellular") info.network = "mobile";
338
+ } catch (_) {}
339
+ try {
340
+ if (Platform.OS === "android") {
341
+ info.model = DeviceInfo.getModel();
342
+ info.manufacturer = await DeviceInfo.getManufacturer();
343
+ info.osVersion = DeviceInfo.getSystemVersion();
344
+ } else {
345
+ // iOS: hardware id (예: "iPhone16,1")
346
+ info.model = await DeviceInfo.getDeviceId();
347
+ info.manufacturer = "Apple";
348
+ info.osVersion = DeviceInfo.getSystemVersion();
349
+ }
350
+ } catch (_) {}
351
+ callNativeCallback("getDeviceInfo", JSON.stringify(info));
352
+ break;
353
+ }
354
+
355
+ // ── registerBackKeyListener (Android only) ────────────────────────
356
+ case "registerBackKeyListener": {
357
+ if (Platform.OS === "android") {
358
+ backKeyListenerRegisteredRef.current = true;
359
+ callNativeCallback("registerBackKeyListener", "1");
360
+ } else {
361
+ callNativeCallback("registerBackKeyListener", "0");
362
+ }
363
+ break;
364
+ }
365
+
366
+ // ── unregisterBackKeyListener (Android only) ──────────────────────
367
+ case "unregisterBackKeyListener": {
368
+ backKeyListenerRegisteredRef.current = false;
369
+ break;
370
+ }
371
+
372
+ // ── swipeBackEnabled (iOS only) ───────────────────────────────────
373
+ case "swipeBackEnabled": {
374
+ if (Platform.OS === "ios") {
375
+ const enabled = params.enabled !== false;
376
+ setSwipeBackEnabled(enabled);
377
+ }
378
+ break;
379
+ }
380
+
381
+ // ── finish ────────────────────────────────────────────────────────
382
+ case "finish": {
383
+ onClose?.();
384
+ break;
385
+ }
386
+
387
+ // ── openTrackingSettings ─────────────────────────────────────────
388
+ case "openTrackingSettings": {
389
+ try { await Linking.openSettings(); } catch (_) {}
390
+ break;
391
+ }
392
+
393
+ // ── openExternalBrowser ───────────────────────────────────────────
394
+ case "openExternalBrowser": {
395
+ const targetUrl = params.url as string | undefined;
396
+ if (targetUrl) {
397
+ await openExternally(normalizeUrl(targetUrl));
398
+ }
399
+ break;
400
+ }
401
+
402
+ // ── pushUrl ───────────────────────────────────────────────────────
403
+ case "pushUrl": {
404
+ const pushTarget = params.url as string | undefined;
405
+ const replace = params.replace === true;
406
+ if (pushTarget) {
407
+ const normalized = JSON.stringify(normalizeUrl(pushTarget));
408
+ if (replace) {
409
+ webViewRef.current?.injectJavaScript(
410
+ `window.location.replace(${normalized}); true;`,
411
+ );
412
+ } else {
413
+ webViewRef.current?.injectJavaScript(
414
+ `window.location.assign(${normalized}); true;`,
415
+ );
416
+ }
417
+ }
418
+ break;
419
+ }
420
+
421
+ default:
422
+ break;
423
+ }
424
+ },
425
+ [callNativeCallback, onClose, openExternally],
426
+ );
427
+
428
+ // ── URL 네비게이션 정책 ──────────────────────────────────────────────────────
429
+ const handleShouldStartLoadWithRequest = useCallback(
430
+ (request: WebViewNavigation): boolean => {
431
+ const uri = request.url;
432
+ if (!uri || uri === "about:blank") return true;
433
+
434
+ if (Platform.OS === "ios") {
435
+ // iOS(Swift SDK 와 동일): 메인 프레임은 항상 평가하고,
436
+ // 서브프레임(iframe)은 사용자가 직접 누른 링크(navigationType 'click')가
437
+ // 외부 대상일 때만 가로챈다 — 광고 iframe 자체 네비게이션이 깨지지 않도록.
438
+ const isTopFrame =
439
+ (request as { isTopFrame?: boolean }).isTopFrame !== false;
440
+ if (isTopFrame) {
441
+ if (shouldOpenExternally(uri)) {
442
+ openExternally(uri);
443
+ return false;
444
+ }
445
+ return true;
446
+ }
447
+ if (request.navigationType === "click" && shouldOpenExternally(uri)) {
448
+ openExternally(uri);
449
+ return false;
450
+ }
451
+ return true;
452
+ }
453
+
454
+ // Android(Kotlin SDK 와 동일): 모든 프레임 평가.
455
+ if (shouldOpenExternally(uri)) {
456
+ openExternally(uri);
457
+ return false;
458
+ }
459
+ return true;
460
+ },
461
+ [shouldOpenExternally, openExternally],
462
+ );
463
+
464
+ // 실제 오퍼월 URL (스킴 보정).
465
+ const realUrl = React.useMemo(() => normalizeUrl(url), [url]);
466
+
467
+ // about:blank 로드 완료 시 1회: (iOS) ATT 요청 → 네이티브 브릿지(AdPopcorn/GMA) 주입 →
468
+ // 실제 오퍼월 URL 로드. 실제 URL 은 브릿지가 바인딩된 뒤 로드되므로 리로드가 필요 없다.
469
+ const handleLoadEnd = useCallback(async () => {
470
+ if (attachStartedRef.current) return; // 실제 URL 로드 완료 시엔 재실행 안 함
471
+ attachStartedRef.current = true;
472
+ if (Platform.OS === "ios" && requestTrackingPermission) {
473
+ try {
474
+ if (await hasTrackingDescription()) {
475
+ await requestTrackingPermission();
476
+ }
477
+ } catch (_) {}
478
+ }
479
+ try {
480
+ await attachAdBridges();
481
+ } catch (_) {}
482
+ setLoadPhase("real");
483
+ }, []);
484
+
485
+ return (
486
+ <View style={styles.container}>
487
+ <WebView
488
+ ref={webViewRef}
489
+ source={loadPhase === "real" ? { uri: realUrl } : { uri: "about:blank" }}
490
+ style={styles.webView}
491
+ // ── JS / 저장소 ────────────────────────────────────────────────────
492
+ javaScriptEnabled
493
+ domStorageEnabled
494
+ // ── window.open 허용 (다른 플랫폼과 동일) ──────────────────────────
495
+ javaScriptCanOpenWindowsAutomatically
496
+ setSupportMultipleWindows
497
+ // ── WebView 내부 광고의 쿠키 기반 타게팅·빈도제한·전환측정 (Android) ──
498
+ thirdPartyCookiesEnabled
499
+ sharedCookiesEnabled
500
+ // ── 미디어 자동재생 ────────────────────────────────────────────────
501
+ mediaPlaybackRequiresUserAction={false}
502
+ allowsInlineMediaPlayback
503
+ bounces={false}
504
+ // allowsPictureInPictureMediaPlayback: react-native-webview 미지원
505
+ // ── Mixed Content (Android) ────────────────────────────────────────
506
+ mixedContentMode="always"
507
+ // ── iOS 스와이프 백 ─────────────────────────────────────────────────
508
+ allowsBackForwardNavigationGestures={swipeBackEnabled}
509
+ // ── 디버그 ─────────────────────────────────────────────────────────
510
+ webviewDebuggingEnabled={debugEnabled}
511
+ // ── window.open 처리 ───────────────────────────────────────────────
512
+ // react-native-webview: onOpenWindow으로 window.open 인터셉트
513
+ onOpenWindow={(syntheticEvent) => {
514
+ const { nativeEvent } = syntheticEvent;
515
+ const targetUrl = nativeEvent.targetUrl;
516
+ if (!targetUrl) return;
517
+ if (shouldOpenExternally(targetUrl)) {
518
+ openExternally(targetUrl);
519
+ } else {
520
+ webViewRef.current?.injectJavaScript(
521
+ `window.location.assign(${JSON.stringify(targetUrl)}); true;`,
522
+ );
523
+ }
524
+ }}
525
+ // ── URL 로딩 정책 ─────────────────────────────────────────────────
526
+ onNavigationStateChange={(navState) => {
527
+ canGoBackRef.current = navState.canGoBack;
528
+ }}
529
+ // ── about:blank 로드 완료 → 네이티브 브릿지 주입 → 실제 URL 로드 ──
530
+ onLoadEnd={handleLoadEnd}
531
+ onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
532
+ // ── JS 폴리필 주입 ─────────────────────────────────────────────────
533
+ injectedJavaScriptBeforeContentLoaded={POLYFILL_SCRIPT}
534
+ // ── JS → Native 메시지 ────────────────────────────────────────────
535
+ onMessage={handleMessage}
536
+ />
537
+ </View>
538
+ );
539
+ },
540
+ );
541
+
542
+ const styles = StyleSheet.create({
543
+ container: {
544
+ flex: 1,
545
+ backgroundColor: "#ffffff",
546
+ },
547
+ webView: {
548
+ flex: 1,
549
+ },
550
+ });
551
+
552
+ export default RewardWebView;