@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 +12 -0
- package/README.md +17 -10
- package/android/build.gradle +4 -0
- package/android/src/main/AndroidManifest.xml +4 -1
- package/android/src/main/java/com/tenqube/visualreward/VisualRewardModule.kt +141 -0
- package/ios/VisualReward.m +24 -0
- package/ios/VisualReward.swift +158 -0
- package/package.json +3 -11
- package/src/NativeVisualReward.ts +87 -0
- package/src/RewardWebView.tsx +22 -66
- package/src/VisualRewardContainer.tsx +42 -18
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.
|
|
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
|
|
23
|
+
이 SDK는 WebView 렌더 표면인 `react-native-webview` **하나만** peer dependency로 요구합니다. 호스트 앱과 **버전이 충돌하지 않도록**(단일 버전 보장) 호스트 앱에 직접 설치하며, 이미 설치되어 있으면 그대로 두면 됩니다.
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
npm install react-native-
|
|
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-
|
|
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
|
-
`
|
|
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가 웹뷰 로드 전
|
|
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
|
-
웹뷰 화면은 항상 세로 모드로
|
|
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
|
-
웹뷰 로드 전 `
|
|
405
|
+
웹뷰 로드 전 네이티브 `ATTrackingManager`로 `NSUserTrackingUsageDescription`이 설정된 경우 ATT 권한을 자동으로 요청합니다. 이미 권한이 결정된 경우 팝업 없이 즉시 진행됩니다. `NSUserTrackingUsageDescription`이 없으면 요청을 스킵하고 바로 로드합니다.
|
|
399
406
|
|
|
400
407
|
### 스와이프 백 (iOS)
|
|
401
408
|
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
package/ios/VisualReward.m
CHANGED
|
@@ -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
|
package/ios/VisualReward.swift
CHANGED
|
@@ -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
|
+
"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.
|
|
36
|
-
"react-native-
|
|
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
|
+
}
|
package/src/RewardWebView.tsx
CHANGED
|
@@ -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
|
-
|
|
153
|
+
lockPortrait();
|
|
170
154
|
return () => {
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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"
|
|
427
|
+
if (Platform.OS === "ios") {
|
|
428
|
+
// 네이티브 ATTrackingManager 로 ATT 요청. NSUserTrackingUsageDescription 이 있을 때만.
|
|
473
429
|
try {
|
|
474
430
|
if (await hasTrackingDescription()) {
|
|
475
|
-
await
|
|
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 라
|
|
107
|
+
{/* Modal 은 별도 window 라 네이티브에서 얻은 inset 을 상/하단 padding 으로 다시 적용한다.
|
|
87
108
|
상단 safe-area 를 흰색으로 두고 상태바 아이콘을 dark-content(검정)로 표시한다. */}
|
|
88
|
-
<
|
|
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
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
};
|