@tenqube/visual-reward-react-native 1.1.3 → 1.1.5

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @tenqube/visual-reward
2
2
 
3
+ ## 1.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - edfa2b1: feat: Flutter·RN SDK 서드파티 의존성 제거 및 네이티브로 대체합니다.
8
+
9
+ ## 1.1.4
10
+
11
+ ### Patch Changes
12
+
13
+ - ee49e4d: feat: 호스트 앱과의 네이티브 모듈 버전 충돌 방지를 위해 모든 네이티브 의존성을 peerDependencies로 이동
14
+
3
15
  ## 1.1.3
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -6,7 +6,7 @@ React Native WebView 기반 리워드 광고 SDK입니다.
6
6
 
7
7
  ## 요구사항
8
8
 
9
- - React Native 0.72+
9
+ - React Native 0.76+
10
10
  - iOS 13.0+
11
11
  - Android minSdk 21
12
12
 
@@ -20,20 +20,24 @@ React Native WebView 기반 리워드 광고 SDK입니다.
20
20
  npm install @tenqube/visual-reward-react-native
21
21
  ```
22
22
 
23
- SDK 의존하는 패키지(`react-native-webview`, `react-native-device-info`, `react-native-idfa-aaid`, `react-native-orientation-locker`, `react-native-permissions`, `@react-native-community/netinfo`)는 함께 설치됩니다. 아래 **peer dependency**만 호스트 앱에 직접 설치합니다.
23
+ SDK WebView 렌더 표면인 `react-native-webview` **하나만** peer dependency로 요구합니다. 호스트 앱과 **버전이 충돌하지 않도록**(단일 버전 보장) 호스트 앱에 직접 설치하며, 이미 설치되어 있으면 그대로 두면 됩니다.
24
24
 
25
25
  ```sh
26
- npm install react-native-safe-area-context
26
+ npm install react-native-webview
27
27
  ```
28
28
 
29
+ > 기기·앱 정보, 광고 식별자(ADID/IDFA), ATT 요청, 방향 고정, safe-area inset 등은 SDK가 네이티브(iOS Swift / Android Kotlin) 모듈로 직접 처리하므로, `react-native-device-info`·`react-native-idfa-aaid`·`@react-native-community/netinfo`·`react-native-permissions`·`react-native-orientation-locker`·`react-native-safe-area-context` 같은 추가 플러그인을 설치할 필요가 없습니다. (호스트 앱의 해당 플러그인 버전과 충돌하지 않습니다.)
30
+
31
+ > 광고 연동(GMA / AdPopcorn SSP)은 **선택적 peer dependency**이며, 사용할 때만 설치합니다. ([AdMob / Ad Manager WebView 광고 연동](#admob--ad-manager-webview-광고-연동-선택) 참고)
32
+
29
33
  ### 방법 2) 수동 설치 (GitHub Release)
30
34
 
31
- npm을 쓰지 않는 경우, [GitHub Release](https://github.com/tenqube/visual-reward-sdk-react-native/releases)에서 `visual-reward-react-native-<버전>.zip`을 내려받아 압축을 풀고 프로젝트 내 원하는 위치에 복사합니다. (zip에는 `android/`, `ios/` 네이티브 모듈이 포함되어 있습니다.) 그런 다음 `package.json`에 `file:` 경로로 추가합니다.
35
+ npm을 쓰지 않는 경우, [GitHub Release](https://github.com/tenqube/visual-reward-sdk-react-native/releases)에서 `visual-reward-react-native-<버전>.zip`을 내려받아 압축을 풀고 프로젝트 내 원하는 위치에 복사합니다. (zip에는 `android/`, `ios/` 네이티브 모듈이 포함되어 있습니다.) 그런 다음 `package.json`에 `file:` 경로 및 `react-native-webview`를 추가합니다.
32
36
 
33
37
  ```json
34
38
  "dependencies": {
35
39
  "@tenqube/visual-reward-react-native": "file:./visual-reward-react-native",
36
- "react-native-safe-area-context": ">=4.0.0"
40
+ "react-native-webview": ">=13.0.0"
37
41
  }
38
42
  ```
39
43
 
@@ -188,10 +192,13 @@ Google [테스트 페이지](https://google.github.io/webview-ads/test/#api-for-
188
192
 
189
193
  ### Android
190
194
 
191
- `AndroidManifest.xml`에 광고 ID 접근 권한을 추가합니다.
195
+ 광고 ID 접근 권한(`com.google.android.gms.permission.AD_ID`)은 SDK가 의존하는 `play-services-ads-identifier`가 매니페스트 병합으로 **자동 추가**하므로, 호스트가 별도로 선언할 필요가 없습니다. (광고 ID 수집하는 앱이 이 권한을 선언하는 것은 Play 정책상 올바른 동작입니다.)
196
+
197
+ 아동 대상 앱 등 광고 ID 권한을 원치 않는 경우에만 호스트 `AndroidManifest.xml`에서 명시적으로 제거할 수 있습니다.
192
198
 
193
199
  ```xml
194
- <uses-permission android:name="com.google.android.gms.permission.AD_ID" />
200
+ <uses-permission android:name="com.google.android.gms.permission.AD_ID"
201
+ tools:node="remove" />
195
202
  ```
196
203
 
197
204
  ### iOS
@@ -203,7 +210,7 @@ IDFA 조회(`getIDFA`)를 사용하려면 `Info.plist`에 ATT 사용 목적 문
203
210
  <string>맞춤형 광고 제공을 위해 기기 식별자를 사용합니다.</string>
204
211
  ```
205
212
 
206
- > ATT 권한 요청은 SDK가 웹뷰 로드 전 자동으로 처리합니다. 이미 권한이 결정된 경우 팝업 없이 즉시 진행됩니다. `NSUserTrackingUsageDescription`이 없으면 요청을 스킵하고 바로 로드합니다.
213
+ > ATT 권한 요청은 SDK가 웹뷰 로드 전 네이티브 `ATTrackingManager`로 자동 처리합니다. 이미 권한이 결정된 경우 팝업 없이 즉시 진행됩니다. `NSUserTrackingUsageDescription`이 없으면 요청을 스킵하고 바로 로드합니다.
207
214
 
208
215
  ---
209
216
 
@@ -382,7 +389,7 @@ await VisualReward.open({
382
389
 
383
390
  ### 가로 모드 차단
384
391
 
385
- 웹뷰 화면은 항상 세로 모드로 고정됩니다(`react-native-orientation-locker`). 화면을 닫으면 기존 방향 설정이 자동으로 복원됩니다.
392
+ 웹뷰 화면은 항상 세로 모드로 고정됩니다. 화면을 닫으면 기존 방향 설정이 자동으로 복원됩니다. (Android는 Activity `requestedOrientation`, iOS는 iOS 16+ 공개 API `requestGeometryUpdate`로 처리하며, iOS에서는 호스트 앱이 지원 방향을 세로로 제한하지 않은 경우 회전 요청이 best-effort로 동작합니다.)
386
393
 
387
394
  ### 영상 광고 자동재생
388
395
 
@@ -395,7 +402,7 @@ await VisualReward.open({
395
402
 
396
403
  ### ATT 권한 요청 (iOS)
397
404
 
398
- 웹뷰 로드 전 `react-native-permissions`를 통해 `NSUserTrackingUsageDescription`이 설정된 경우 ATT 권한을 자동으로 요청합니다. 이미 권한이 결정된 경우 팝업 없이 즉시 진행됩니다. `NSUserTrackingUsageDescription`이 없으면 요청을 스킵하고 바로 로드합니다.
405
+ 웹뷰 로드 전 네이티브 `ATTrackingManager`로 `NSUserTrackingUsageDescription`이 설정된 경우 ATT 권한을 자동으로 요청합니다. 이미 권한이 결정된 경우 팝업 없이 즉시 진행됩니다. `NSUserTrackingUsageDescription`이 없으면 요청을 스킵하고 바로 로드합니다.
399
406
 
400
407
  ### 스와이프 백 (iOS)
401
408
 
@@ -42,6 +42,10 @@ repositories {
42
42
  dependencies {
43
43
  // React Native (autolink 이 버전 주입)
44
44
  implementation "com.facebook.react:react-native:+"
45
+ // ADID(AAID) 조회용. react-native-idfa-aaid 제거에 따라 직접 선언해 ADID 를 계속 보장한다.
46
+ // (이 라이브러리가 com.google.android.gms.permission.AD_ID 권한을 매니페스트 머지로 가져오며,
47
+ // ADID 를 수집하는 앱이 해당 권한을 선언하는 것은 Play 정책상 올바른 동작이다.)
48
+ implementation "com.google.android.gms:play-services-ads-identifier:18.2.0"
45
49
  // GMA / AdPopcorn / react-native-webview 는 리플렉션·뷰탐색으로만 접근하므로
46
50
  // 컴파일 의존성 없음(호스트 앱이 각 SDK 를 링크하면 리플렉션이 동작, 없으면 no-op).
47
51
  }
@@ -1,2 +1,5 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
- package="com.tenqube.visualreward" />
2
+ package="com.tenqube.visualreward">
3
+ <!-- 네트워크 타입(wifi/mobile) 조회용 — @react-native-community/netinfo 제거에 따라 직접 선언. -->
4
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5
+ </manifest>
@@ -4,16 +4,27 @@ import android.app.Activity
4
4
  import android.content.ActivityNotFoundException
5
5
  import android.content.Context
6
6
  import android.content.Intent
7
+ import android.content.pm.ActivityInfo
8
+ import android.content.pm.PackageManager
9
+ import android.net.ConnectivityManager
10
+ import android.net.NetworkCapabilities
7
11
  import android.net.Uri
12
+ import android.os.Build
8
13
  import android.util.Log
9
14
  import android.view.View
10
15
  import android.view.ViewGroup
11
16
  import android.webkit.WebView
17
+ import androidx.core.view.ViewCompat
18
+ import androidx.core.view.WindowInsetsCompat
19
+ import com.facebook.react.bridge.Arguments
12
20
  import com.facebook.react.bridge.Promise
13
21
  import com.facebook.react.bridge.ReactApplicationContext
14
22
  import com.facebook.react.bridge.ReactContextBaseJavaModule
15
23
  import com.facebook.react.bridge.ReactMethod
16
24
  import com.facebook.react.bridge.UiThreadUtil
25
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient
26
+ import org.json.JSONException
27
+ import org.json.JSONObject
17
28
 
18
29
  /**
19
30
  * Tenqube Visual Reward — React Native 네이티브 모듈 (Android).
@@ -98,6 +109,136 @@ class VisualRewardModule(private val reactContext: ReactApplicationContext) :
98
109
  }
99
110
  }
100
111
 
112
+ // MARK: - 기기/앱 정보 · 광고 ID · ATT · 방향 · safe-area
113
+ // (react-native-device-info / idfa-aaid / netinfo / permissions / orientation-locker /
114
+ // safe-area-context 제거에 따라, Kotlin SDK 의 동일 로직을 네이티브 메서드로 직접 제공한다.)
115
+
116
+ @ReactMethod
117
+ fun getAppVersion(promise: Promise) {
118
+ promise.resolve(getAppVersionName())
119
+ }
120
+
121
+ @ReactMethod
122
+ fun getAdvertisingId(promise: Promise) {
123
+ // AdvertisingIdClient.getAdvertisingIdInfo 는 블로킹 호출 → 별도 스레드에서 실행.
124
+ Thread {
125
+ var adId = ""
126
+ try {
127
+ val id = AdvertisingIdClient.getAdvertisingIdInfo(reactApplicationContext).id ?: ""
128
+ if (id != "00000000-0000-0000-0000-000000000000") adId = id
129
+ } catch (_: Throwable) {
130
+ }
131
+ promise.resolve(adId)
132
+ }.start()
133
+ }
134
+
135
+ @ReactMethod
136
+ fun getIDFV(promise: Promise) {
137
+ promise.resolve("") // iOS 전용 — Android no-op
138
+ }
139
+
140
+ @ReactMethod
141
+ fun getDeviceInfo(promise: Promise) {
142
+ promise.resolve(getDeviceInfoJson())
143
+ }
144
+
145
+ @ReactMethod
146
+ fun requestTrackingAuthorization(promise: Promise) {
147
+ promise.resolve(null) // iOS 전용 — Android no-op
148
+ }
149
+
150
+ @ReactMethod
151
+ fun lockPortrait(promise: Promise) {
152
+ UiThreadUtil.runOnUiThread {
153
+ reactApplicationContext.currentActivity?.requestedOrientation =
154
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
155
+ promise.resolve(null)
156
+ }
157
+ }
158
+
159
+ @ReactMethod
160
+ fun unlockOrientations(promise: Promise) {
161
+ UiThreadUtil.runOnUiThread {
162
+ // 기존 방향 설정 복원 (orientation-locker.unlockAllOrientations 와 동일)
163
+ reactApplicationContext.currentActivity?.requestedOrientation =
164
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
165
+ promise.resolve(null)
166
+ }
167
+ }
168
+
169
+ @ReactMethod
170
+ fun getSafeAreaInsets(promise: Promise) {
171
+ UiThreadUtil.runOnUiThread {
172
+ val map = Arguments.createMap()
173
+ var top = 0.0
174
+ var bottom = 0.0
175
+ var left = 0.0
176
+ var right = 0.0
177
+ try {
178
+ val decor = reactApplicationContext.currentActivity?.window?.decorView
179
+ val density = reactApplicationContext.resources.displayMetrics.density.toDouble()
180
+ val insets = decor
181
+ ?.let { ViewCompat.getRootWindowInsets(it) }
182
+ ?.getInsets(WindowInsetsCompat.Type.systemBars())
183
+ if (insets != null && density > 0) {
184
+ top = insets.top / density
185
+ bottom = insets.bottom / density
186
+ left = insets.left / density
187
+ right = insets.right / density
188
+ }
189
+ } catch (_: Exception) {
190
+ }
191
+ map.putDouble("top", top)
192
+ map.putDouble("bottom", bottom)
193
+ map.putDouble("left", left)
194
+ map.putDouble("right", right)
195
+ promise.resolve(map)
196
+ }
197
+ }
198
+
199
+ private fun getAppVersionName(): String {
200
+ return try {
201
+ val ctx = reactApplicationContext
202
+ val pm = ctx.packageManager
203
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
204
+ pm.getPackageInfo(ctx.packageName, PackageManager.PackageInfoFlags.of(0)).versionName ?: ""
205
+ } else {
206
+ @Suppress("DEPRECATION")
207
+ pm.getPackageInfo(ctx.packageName, 0).versionName ?: ""
208
+ }
209
+ } catch (_: Exception) {
210
+ ""
211
+ }
212
+ }
213
+
214
+ private fun getNetworkType(): String {
215
+ val cm = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
216
+ as? ConnectivityManager ?: return ""
217
+ return try {
218
+ val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return "") ?: return ""
219
+ when {
220
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
221
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
222
+ else -> ""
223
+ }
224
+ } catch (_: Exception) {
225
+ ""
226
+ }
227
+ }
228
+
229
+ private fun getDeviceInfoJson(): String {
230
+ val info = JSONObject()
231
+ try {
232
+ val net = getNetworkType()
233
+ if (net.isNotEmpty()) info.put("network", net)
234
+ info.put("model", Build.MODEL)
235
+ info.put("manufacturer", Build.MANUFACTURER)
236
+ info.put("osVersion", Build.VERSION.RELEASE)
237
+ } catch (_: JSONException) {
238
+ }
239
+ return info.toString()
240
+ }
241
+
101
242
  // MARK: - GMA registerWebView (리플렉션)
102
243
  // Kotlin SDK RewardWebViewActivity.onCreate 의 등록 로직과 동일.
103
244
 
@@ -9,4 +9,28 @@ RCT_EXTERN_METHOD(attachAdBridges:(RCTPromiseResolveBlock)resolve
9
9
  RCT_EXTERN_METHOD(hasTrackingDescription:(RCTPromiseResolveBlock)resolve
10
10
  rejecter:(RCTPromiseRejectBlock)reject)
11
11
 
12
+ RCT_EXTERN_METHOD(getAppVersion:(RCTPromiseResolveBlock)resolve
13
+ rejecter:(RCTPromiseRejectBlock)reject)
14
+
15
+ RCT_EXTERN_METHOD(getAdvertisingId:(RCTPromiseResolveBlock)resolve
16
+ rejecter:(RCTPromiseRejectBlock)reject)
17
+
18
+ RCT_EXTERN_METHOD(getIDFV:(RCTPromiseResolveBlock)resolve
19
+ rejecter:(RCTPromiseRejectBlock)reject)
20
+
21
+ RCT_EXTERN_METHOD(getDeviceInfo:(RCTPromiseResolveBlock)resolve
22
+ rejecter:(RCTPromiseRejectBlock)reject)
23
+
24
+ RCT_EXTERN_METHOD(requestTrackingAuthorization:(RCTPromiseResolveBlock)resolve
25
+ rejecter:(RCTPromiseRejectBlock)reject)
26
+
27
+ RCT_EXTERN_METHOD(lockPortrait:(RCTPromiseResolveBlock)resolve
28
+ rejecter:(RCTPromiseRejectBlock)reject)
29
+
30
+ RCT_EXTERN_METHOD(unlockOrientations:(RCTPromiseResolveBlock)resolve
31
+ rejecter:(RCTPromiseRejectBlock)reject)
32
+
33
+ RCT_EXTERN_METHOD(getSafeAreaInsets:(RCTPromiseResolveBlock)resolve
34
+ rejecter:(RCTPromiseRejectBlock)reject)
35
+
12
36
  @end
@@ -1,4 +1,7 @@
1
+ import AdSupport
2
+ import AppTrackingTransparency
1
3
  import Foundation
4
+ import Network
2
5
  import React
3
6
  import UIKit
4
7
  import WebKit
@@ -14,6 +17,11 @@ import WebKit
14
17
  @objc(VisualReward)
15
18
  class VisualReward: NSObject {
16
19
 
20
+ override init() {
21
+ super.init()
22
+ VisualReward.startNetworkMonitor()
23
+ }
24
+
17
25
  @objc static func requiresMainQueueSetup() -> Bool {
18
26
  return false
19
27
  }
@@ -46,6 +54,156 @@ class VisualReward: NSObject {
46
54
  resolve(has)
47
55
  }
48
56
 
57
+ // MARK: - 기기/앱 정보 · IDFA/IDFV · ATT · 방향 · safe-area
58
+ // (react-native-device-info / idfa-aaid / netinfo / permissions / orientation-locker /
59
+ // safe-area-context 제거에 따라, Swift SDK 의 동일 로직을 네이티브 메서드로 직접 제공한다.)
60
+
61
+ @objc(getAppVersion:rejecter:)
62
+ func getAppVersion(_ resolve: @escaping RCTPromiseResolveBlock,
63
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
64
+ resolve(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
65
+ }
66
+
67
+ @objc(getAdvertisingId:rejecter:)
68
+ func getAdvertisingId(_ resolve: @escaping RCTPromiseResolveBlock,
69
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
70
+ resolve(VisualReward.advertisingId())
71
+ }
72
+
73
+ @objc(getIDFV:rejecter:)
74
+ func getIDFV(_ resolve: @escaping RCTPromiseResolveBlock,
75
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
76
+ resolve(UIDevice.current.identifierForVendor?.uuidString ?? "")
77
+ }
78
+
79
+ @objc(getDeviceInfo:rejecter:)
80
+ func getDeviceInfo(_ resolve: @escaping RCTPromiseResolveBlock,
81
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
82
+ resolve(VisualReward.deviceInfoJson())
83
+ }
84
+
85
+ /// Swift SDK 의 ATTrackingManager.requestTrackingAuthorization 과 동일.
86
+ @objc(requestTrackingAuthorization:rejecter:)
87
+ func requestTrackingAuthorization(_ resolve: @escaping RCTPromiseResolveBlock,
88
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
89
+ if #available(iOS 14, *) {
90
+ ATTrackingManager.requestTrackingAuthorization { _ in
91
+ DispatchQueue.main.async { resolve(nil) }
92
+ }
93
+ } else {
94
+ resolve(nil)
95
+ }
96
+ }
97
+
98
+ /// 세로 모드 고정. iOS 16+ 공개 API requestGeometryUpdate 로 best-effort 회전 요청.
99
+ /// (비공개 API·스위즐링을 쓰지 않으므로, 최상단 VC 가 세로를 허용하지 않으면 조용히 무시된다.)
100
+ @objc(lockPortrait:rejecter:)
101
+ func lockPortrait(_ resolve: @escaping RCTPromiseResolveBlock,
102
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
103
+ DispatchQueue.main.async {
104
+ if #available(iOS 16.0, *) {
105
+ VisualReward.windowScene()?.requestGeometryUpdate(
106
+ .iOS(interfaceOrientations: .portrait)
107
+ ) { _ in }
108
+ }
109
+ resolve(nil)
110
+ }
111
+ }
112
+
113
+ @objc(unlockOrientations:rejecter:)
114
+ func unlockOrientations(_ resolve: @escaping RCTPromiseResolveBlock,
115
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
116
+ DispatchQueue.main.async {
117
+ if #available(iOS 16.0, *) {
118
+ VisualReward.windowScene()?.requestGeometryUpdate(
119
+ .iOS(interfaceOrientations: .all)
120
+ ) { _ in }
121
+ }
122
+ resolve(nil)
123
+ }
124
+ }
125
+
126
+ @objc(getSafeAreaInsets:rejecter:)
127
+ func getSafeAreaInsets(_ resolve: @escaping RCTPromiseResolveBlock,
128
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
129
+ DispatchQueue.main.async {
130
+ let insets = VisualReward.keyWindow()?.safeAreaInsets ?? .zero
131
+ resolve([
132
+ "top": insets.top,
133
+ "bottom": insets.bottom,
134
+ "left": insets.left,
135
+ "right": insets.right,
136
+ ])
137
+ }
138
+ }
139
+
140
+ // MARK: - 기기/광고 정보 헬퍼 (Swift SDK RewardWebViewController 의 동일 로직 복제)
141
+
142
+ private static var networkMonitor: NWPathMonitor?
143
+ // networkType 은 NWPathMonitor 백그라운드 큐에서 쓰이고 브릿지 큐에서 읽히므로
144
+ // Swift SDK(RewardWebViewController)와 동일하게 serial 큐로 접근을 보호한다.
145
+ private static let networkQueue = DispatchQueue(label: "com.tenqube.visualreward.network", qos: .utility)
146
+ private static var _networkType = ""
147
+ private static var networkType: String {
148
+ get { networkQueue.sync { _networkType } }
149
+ set { networkQueue.async { _networkType = newValue } }
150
+ }
151
+
152
+ private static func startNetworkMonitor() {
153
+ if networkMonitor != nil { return }
154
+ let monitor = NWPathMonitor()
155
+ monitor.pathUpdateHandler = { path in
156
+ if path.usesInterfaceType(.wifi) {
157
+ VisualReward.networkType = "wifi"
158
+ } else if path.usesInterfaceType(.cellular) {
159
+ VisualReward.networkType = "mobile"
160
+ } else {
161
+ VisualReward.networkType = ""
162
+ }
163
+ }
164
+ monitor.start(queue: DispatchQueue.global(qos: .background))
165
+ networkMonitor = monitor
166
+ }
167
+
168
+ private static func deviceInfoJson() -> String {
169
+ var info: [String: Any] = [:]
170
+ if !networkType.isEmpty { info["network"] = networkType }
171
+ info["model"] = machineIdentifier()
172
+ info["manufacturer"] = "Apple"
173
+ info["osVersion"] = UIDevice.current.systemVersion
174
+ guard let data = try? JSONSerialization.data(withJSONObject: info),
175
+ let json = String(data: data, encoding: .utf8) else { return "{}" }
176
+ return json
177
+ }
178
+
179
+ private static func advertisingId() -> String {
180
+ // Swift SDK getIDFA 와 동일: iOS14+ 는 ATT 승인 시에만, 이하는 추적 허용 시에만 반환.
181
+ let idfa: String
182
+ if #available(iOS 14, *) {
183
+ idfa = (ATTrackingManager.trackingAuthorizationStatus == .authorized)
184
+ ? ASIdentifierManager.shared().advertisingIdentifier.uuidString : ""
185
+ } else {
186
+ idfa = ASIdentifierManager.shared().isAdvertisingTrackingEnabled
187
+ ? ASIdentifierManager.shared().advertisingIdentifier.uuidString : ""
188
+ }
189
+ return (idfa == "00000000-0000-0000-0000-000000000000") ? "" : idfa
190
+ }
191
+
192
+ private static func machineIdentifier() -> String {
193
+ var size = 0
194
+ sysctlbyname("hw.machine", nil, &size, nil, 0)
195
+ var machine = [CChar](repeating: 0, count: size)
196
+ sysctlbyname("hw.machine", &machine, &size, nil, 0)
197
+ return String(cString: machine)
198
+ }
199
+
200
+ private static func windowScene() -> UIWindowScene? {
201
+ return UIApplication.shared.connectedScenes
202
+ .compactMap { $0 as? UIWindowScene }
203
+ .first { $0.activationState == .foregroundActive }
204
+ ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
205
+ }
206
+
49
207
  // MARK: - GMA registerWebView (리플렉션)
50
208
  // Swift SDK RewardWebViewController.setupLayout 의 GADMobileAds.registerWebView 와 동일.
51
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenqube/visual-reward-react-native",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Tenqube Visual Reward SDK for React Native",
5
5
  "main": "index.ts",
6
6
  "types": "index.ts",
@@ -22,18 +22,10 @@
22
22
  "README.md",
23
23
  "CHANGELOG.md"
24
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
25
  "peerDependencies": {
34
26
  "react": ">=18.0.0",
35
- "react-native": ">=0.72.0",
36
- "react-native-safe-area-context": ">=4.0.0",
27
+ "react-native": ">=0.76.0",
28
+ "react-native-webview": ">=13.0.0",
37
29
  "react-native-google-mobile-ads": "*",
38
30
  "react-native-adpopcorn-ssp": "*"
39
31
  },
@@ -9,10 +9,25 @@ import { NativeModules, Platform } from "react-native";
9
9
  *
10
10
  * 네이티브 모듈이 링크되지 않은 환경(예: 자동링크 누락)에서도 앱이 죽지 않도록 모든 호출을 방어한다.
11
11
  */
12
+ export interface SafeAreaInsets {
13
+ top: number;
14
+ bottom: number;
15
+ left: number;
16
+ right: number;
17
+ }
18
+
12
19
  interface VisualRewardNativeModule {
13
20
  attachAdBridges(): Promise<boolean>;
14
21
  hasTrackingDescription(): Promise<boolean>;
15
22
  handleIntentScheme(url: string): Promise<boolean>;
23
+ getAppVersion(): Promise<string>;
24
+ getAdvertisingId(): Promise<string>;
25
+ getIDFV(): Promise<string>;
26
+ getDeviceInfo(): Promise<string>;
27
+ requestTrackingAuthorization(): Promise<void>;
28
+ lockPortrait(): Promise<void>;
29
+ unlockOrientations(): Promise<void>;
30
+ getSafeAreaInsets(): Promise<SafeAreaInsets>;
16
31
  }
17
32
 
18
33
  const NativeModule: Partial<VisualRewardNativeModule> | undefined =
@@ -50,3 +65,75 @@ export async function handleIntentScheme(url: string): Promise<boolean> {
50
65
  return false;
51
66
  }
52
67
  }
68
+
69
+ /** 앱 버전(CFBundleShortVersionString / versionName). */
70
+ export async function getAppVersion(): Promise<string> {
71
+ try {
72
+ return (await NativeModule?.getAppVersion?.()) ?? "";
73
+ } catch (_) {
74
+ return "";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 광고 식별자. Android: AAID(AdvertisingIdClient). iOS: IDFA(ASIdentifierManager, ATT 승인 시에만).
80
+ * opt-out/실패 시 빈 문자열. (제로 UUID 필터링은 네이티브에서 처리)
81
+ */
82
+ export async function getAdvertisingId(): Promise<string> {
83
+ try {
84
+ return (await NativeModule?.getAdvertisingId?.()) ?? "";
85
+ } catch (_) {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ /** iOS: IDFV(identifierForVendor). (Android/미링크 → 빈 문자열) */
91
+ export async function getIDFV(): Promise<string> {
92
+ if (Platform.OS !== "ios") return "";
93
+ try {
94
+ return (await NativeModule?.getIDFV?.()) ?? "";
95
+ } catch (_) {
96
+ return "";
97
+ }
98
+ }
99
+
100
+ /** 기기 정보 JSON 문자열(network/model/manufacturer/osVersion). 네트워크 타입도 네이티브에서 합쳐 반환. */
101
+ export async function getDeviceInfo(): Promise<string> {
102
+ try {
103
+ return (await NativeModule?.getDeviceInfo?.()) ?? "{}";
104
+ } catch (_) {
105
+ return "{}";
106
+ }
107
+ }
108
+
109
+ /** iOS: ATT 추적 권한 요청(ATTrackingManager). (Android/미링크 → no-op) */
110
+ export async function requestTrackingAuthorization(): Promise<void> {
111
+ if (Platform.OS !== "ios") return;
112
+ try {
113
+ await NativeModule?.requestTrackingAuthorization?.();
114
+ } catch (_) {}
115
+ }
116
+
117
+ /** 세로 모드로 고정. Android: requestedOrientation. iOS: requestGeometryUpdate(iOS16+, best-effort). */
118
+ export async function lockPortrait(): Promise<void> {
119
+ try {
120
+ await NativeModule?.lockPortrait?.();
121
+ } catch (_) {}
122
+ }
123
+
124
+ /** 방향 고정 해제(기존 설정 복원). */
125
+ export async function unlockOrientations(): Promise<void> {
126
+ try {
127
+ await NativeModule?.unlockOrientations?.();
128
+ } catch (_) {}
129
+ }
130
+
131
+ /** 현재 safe-area inset(top/bottom/left/right, dp/pt). 미링크 시 0. */
132
+ export async function getSafeAreaInsets(): Promise<SafeAreaInsets> {
133
+ const zero = { top: 0, bottom: 0, left: 0, right: 0 };
134
+ try {
135
+ return (await NativeModule?.getSafeAreaInsets?.()) ?? zero;
136
+ } catch (_) {
137
+ return zero;
138
+ }
139
+ }
@@ -15,38 +15,22 @@ import {
15
15
  } from "react-native";
16
16
  import WebView, { WebViewMessageEvent } from "react-native-webview";
17
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
18
 
23
19
  import type { RewardWebViewHandle, RewardWebViewProps } from "./types";
24
20
  import { isAllowedDomain, normalizeUrl } from "./utils";
25
21
  import {
26
22
  attachAdBridges,
23
+ getAdvertisingId,
24
+ getAppVersion,
25
+ getDeviceInfo,
26
+ getIDFV,
27
27
  handleIntentScheme,
28
28
  hasTrackingDescription,
29
+ lockPortrait,
30
+ requestTrackingAuthorization,
31
+ unlockOrientations,
29
32
  } from "./NativeVisualReward";
30
33
 
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
34
  // ─── 폴리필 스크립트 ──────────────────────────────────────────────────────────
51
35
  /**
52
36
  * Android / iOS 양쪽 호출 방식을 통합하는 폴리필을 웹뷰에 주입합니다.
@@ -166,9 +150,9 @@ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
166
150
 
167
151
  // ── 세로 모드 고정 ──────────────────────────────────────────────────────────
168
152
  useEffect(() => {
169
- Orientation.lockToPortrait();
153
+ lockPortrait();
170
154
  return () => {
171
- Orientation.unlockAllOrientations();
155
+ unlockOrientations();
172
156
  };
173
157
  }, []);
174
158
 
@@ -280,10 +264,7 @@ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
280
264
  switch (fncName) {
281
265
  // ── getAppVersion ──────────────────────────────────────────────────
282
266
  case "getAppVersion": {
283
- let version = "";
284
- try {
285
- version = DeviceInfo.getVersion();
286
- } catch (_) {}
267
+ const version = await getAppVersion();
287
268
  callNativeCallback("getAppVersion", version);
288
269
  break;
289
270
  }
@@ -292,11 +273,8 @@ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
292
273
  case "getADID": {
293
274
  let adId = "";
294
275
  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 (_) {}
276
+ // 네이티브(AdvertisingIdClient)에 위임. 제로 UUID 필터링도 네이티브에서 처리.
277
+ adId = await getAdvertisingId();
300
278
  }
301
279
  callNativeCallback("getADID", adId);
302
280
  break;
@@ -306,11 +284,8 @@ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
306
284
  case "getIDFA": {
307
285
  let idfa = "";
308
286
  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 (_) {}
287
+ // 네이티브(ASIdentifierManager)에 위임. Swift 와 동일하게 ATT 승인 시에만 반환.
288
+ idfa = await getAdvertisingId();
314
289
  }
315
290
  callNativeCallback("getIDFA", idfa);
316
291
  break;
@@ -318,37 +293,17 @@ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
318
293
 
319
294
  // ── getIDFV (iOS only) ────────────────────────────────────────────
320
295
  case "getIDFV": {
321
- let idfv = "";
322
- if (Platform.OS === "ios") {
323
- try {
324
- idfv = await DeviceInfo.getUniqueId();
325
- } catch (_) {}
326
- }
296
+ const idfv = await getIDFV();
327
297
  callNativeCallback("getIDFV", idfv);
328
298
  break;
329
299
  }
330
300
 
331
301
  // ── getDeviceInfo ─────────────────────────────────────────────────
332
302
  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));
303
+ // 네이티브에서 network/model/manufacturer/osVersion JSON 문자열로 조립해 반환.
304
+ // 웹 콜백은 문자열을 받아 JSON.parse 하므로 rawValue=false(기본)로 감싼다.
305
+ const info = await getDeviceInfo();
306
+ callNativeCallback("getDeviceInfo", info);
352
307
  break;
353
308
  }
354
309
 
@@ -469,10 +424,11 @@ const RewardWebView = React.forwardRef<RewardWebViewHandle, RewardWebViewProps>(
469
424
  const handleLoadEnd = useCallback(async () => {
470
425
  if (attachStartedRef.current) return; // 실제 URL 로드 완료 시엔 재실행 안 함
471
426
  attachStartedRef.current = true;
472
- if (Platform.OS === "ios" && requestTrackingPermission) {
427
+ if (Platform.OS === "ios") {
428
+ // 네이티브 ATTrackingManager 로 ATT 요청. NSUserTrackingUsageDescription 이 있을 때만.
473
429
  try {
474
430
  if (await hasTrackingDescription()) {
475
- await requestTrackingPermission();
431
+ await requestTrackingAuthorization();
476
432
  }
477
433
  } catch (_) {}
478
434
  }
@@ -7,11 +7,13 @@ import {
7
7
  StyleSheet,
8
8
  View,
9
9
  } from "react-native";
10
- import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
11
10
  import RewardWebView from "./RewardWebView";
12
11
  import { VisualReward } from "./VisualReward";
12
+ import { getSafeAreaInsets, type SafeAreaInsets } from "./NativeVisualReward";
13
13
  import type { RewardWebViewHandle, RewardWebViewProps } from "./types";
14
14
 
15
+ const ZERO_INSETS: SafeAreaInsets = { top: 0, bottom: 0, left: 0, right: 0 };
16
+
15
17
  /**
16
18
  * VisualRewardContainer
17
19
  *
@@ -41,6 +43,20 @@ const VisualRewardContainer: React.FC = () => {
41
43
  const optionsRef = useRef<RewardWebViewProps | null>(null);
42
44
  optionsRef.current = options;
43
45
 
46
+ // safe-area inset — react-native-safe-area-context 대신 네이티브에서 조회한다.
47
+ // (Modal 은 별도 window 라 상/하단 inset 을 다시 적용해야 상태바·제스처바에 안 가린다.)
48
+ const [insets, setInsets] = useState<SafeAreaInsets>(ZERO_INSETS);
49
+ useEffect(() => {
50
+ if (!visible) return;
51
+ let cancelled = false;
52
+ getSafeAreaInsets().then((v) => {
53
+ if (!cancelled) setInsets(v);
54
+ });
55
+ return () => {
56
+ cancelled = true;
57
+ };
58
+ }, [visible]);
59
+
44
60
  // VisualReward 싱글톤에 핸들러 등록
45
61
  useEffect(() => {
46
62
  VisualReward._register((opts) => {
@@ -80,28 +96,36 @@ const VisualRewardContainer: React.FC = () => {
80
96
  visible={visible}
81
97
  animationType="slide"
82
98
  presentationStyle="fullScreen"
99
+ // Android: 상/하단 모두 edge-to-edge 로 둔다. targetSdk 35+ 는 Activity 가 edge-to-edge 로
100
+ // 강제되므로, Modal 하단도 네비게이션 바 아래까지 그려지게 해야(navigationBarTranslucent)
101
+ // 네이티브 systemBars inset 을 padding 으로 적용했을 때 이중 여백이 생기지 않는다.
102
+ // (navigationBarTranslucent 는 statusBarTranslucent 가 true 여야 동작)
83
103
  statusBarTranslucent={Platform.OS === "android"}
104
+ navigationBarTranslucent={Platform.OS === "android"}
84
105
  onRequestClose={handleBackPress}
85
106
  >
86
- {/* Modal 은 별도 window 라 자체 SafeAreaProvider inset 을 다시 측정한다.
107
+ {/* Modal 은 별도 window 라 네이티브에서 얻은 inset 을 상/하단 padding 으로 다시 적용한다.
87
108
  상단 safe-area 를 흰색으로 두고 상태바 아이콘을 dark-content(검정)로 표시한다. */}
88
- <SafeAreaProvider style={styles.safeArea}>
109
+ <View
110
+ style={[
111
+ styles.safeArea,
112
+ { paddingTop: insets.top, paddingBottom: insets.bottom },
113
+ ]}
114
+ >
89
115
  <StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
90
- <SafeAreaView style={styles.safeArea} edges={["top", "bottom"]}>
91
- <View style={styles.container}>
92
- {options && (
93
- <RewardWebView
94
- ref={rewardWebViewRef}
95
- key={options.url}
96
- url={options.url}
97
- allowedDomains={options.allowedDomains}
98
- debugEnabled={options.debugEnabled ?? false}
99
- onClose={handleClose}
100
- />
101
- )}
102
- </View>
103
- </SafeAreaView>
104
- </SafeAreaProvider>
116
+ <View style={styles.container}>
117
+ {options && (
118
+ <RewardWebView
119
+ ref={rewardWebViewRef}
120
+ key={options.url}
121
+ url={options.url}
122
+ allowedDomains={options.allowedDomains}
123
+ debugEnabled={options.debugEnabled ?? false}
124
+ onClose={handleClose}
125
+ />
126
+ )}
127
+ </View>
128
+ </View>
105
129
  </Modal>
106
130
  );
107
131
  };