@1selfworld/adchain-sdk-react-native 1.0.1

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,792 @@
1
+ import Foundation
2
+ import React
3
+ import AdchainSDK
4
+ import UIKit
5
+
6
+ @objc(AdchainSdk)
7
+ class AdchainSdkModule: RCTEventEmitter {
8
+
9
+ // Helper function to get the top view controller
10
+ private func getTopViewController() -> UIViewController? {
11
+ guard let windowScene = UIApplication.shared.connectedScenes
12
+ .compactMap({ $0 as? UIWindowScene })
13
+ .first,
14
+ let window = windowScene.windows.first(where: { $0.isKeyWindow }),
15
+ let rootViewController = window.rootViewController else {
16
+ return nil
17
+ }
18
+
19
+ var topController = rootViewController
20
+ while let presentedViewController = topController.presentedViewController {
21
+ topController = presentedViewController
22
+ }
23
+
24
+ return topController
25
+ }
26
+
27
+ // MARK: - Properties
28
+
29
+ // 내부 인스턴스 관리 (자동 생성/캐싱)
30
+ private var quizInstances: [String: AdchainQuiz] = [:]
31
+ private var missionInstances: [String: AdchainMission] = [:]
32
+
33
+ // MARK: - RCTEventEmitter
34
+
35
+ @objc override static func requiresMainQueueSetup() -> Bool { true }
36
+
37
+ override func supportedEvents() -> [String]! {
38
+ return ["onQuizCompleted", "onMissionCompleted", "onMissionProgressed", "onMissionRefreshed"] // Quiz, Mission 이벤트 지원
39
+ }
40
+
41
+ // MARK: - 1. SDK 초기화
42
+
43
+ @objc func initialize(_ appKey: NSString,
44
+ appSecret: NSString,
45
+ options: NSDictionary?,
46
+ resolver: @escaping RCTPromiseResolveBlock,
47
+ rejecter: @escaping RCTPromiseRejectBlock) {
48
+
49
+ // options에서 environment와 timeout 추출
50
+ let environment = options?["environment"] as? String
51
+ let timeout = options?["timeout"] as? NSNumber
52
+
53
+ let env: AdchainSdkConfig.Environment
54
+ if let envString = environment {
55
+ switch envString.uppercased() {
56
+ case "STAGING":
57
+ env = .staging
58
+ case "DEVELOPMENT":
59
+ env = .development
60
+ default:
61
+ env = .production
62
+ }
63
+ } else {
64
+ env = .production
65
+ }
66
+
67
+ var configBuilder = AdchainSdkConfig.Builder(appKey: appKey as String, appSecret: appSecret as String)
68
+ .setEnvironment(env)
69
+
70
+ if let timeoutValue = timeout {
71
+ configBuilder = configBuilder.setTimeout(TimeInterval(timeoutValue.doubleValue))
72
+ }
73
+
74
+ let config = configBuilder.build()
75
+
76
+ // UI 관련 작업을 메인 스레드에서 실행
77
+ DispatchQueue.main.async {
78
+ if UIApplication.shared.delegate?.window??.rootViewController?.view.window?.windowScene?.delegate is UIWindowSceneDelegate {
79
+ AdchainSdk.shared.initialize(application: UIApplication.shared, sdkConfig: config)
80
+ resolver([
81
+ "success": true,
82
+ "message": "SDK initialized successfully"
83
+ ])
84
+ } else {
85
+ // Fallback
86
+ AdchainSdk.shared.initialize(application: UIApplication.shared, sdkConfig: config)
87
+ resolver([
88
+ "success": true,
89
+ "message": "SDK initialized successfully"
90
+ ])
91
+ }
92
+ }
93
+ }
94
+
95
+ // MARK: - 2. 인증 관련 (4개)
96
+
97
+ @objc func login(_ userId: NSString,
98
+ userInfo: NSDictionary?,
99
+ resolver: @escaping RCTPromiseResolveBlock,
100
+ rejecter: @escaping RCTPromiseRejectBlock) {
101
+
102
+ // userInfo에서 값 추출
103
+ let gender = userInfo?["gender"] as? String
104
+ let birthYear = userInfo?["birthYear"] as? NSNumber
105
+ let customProperties = userInfo?["customProperties"] as? NSDictionary
106
+
107
+ var userGender: AdchainSdkUser.Gender = .other
108
+ if let genderString = gender {
109
+ switch genderString.uppercased() {
110
+ case "MALE", "M":
111
+ userGender = .male
112
+ case "FEMALE", "F":
113
+ userGender = .female
114
+ default:
115
+ userGender = .other
116
+ }
117
+ }
118
+
119
+ let user = AdchainSdkUser(
120
+ userId: userId as String,
121
+ gender: userGender,
122
+ birthYear: birthYear?.intValue
123
+ )
124
+
125
+ // Custom properties 처리 (iOS SDK가 지원한다면)
126
+ // 현재는 생략
127
+
128
+ class LoginListenerImpl: NSObject, AdchainSdkLoginListener {
129
+ let resolver: RCTPromiseResolveBlock
130
+ let rejecter: RCTPromiseRejectBlock
131
+
132
+ init(resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) {
133
+ self.resolver = resolver
134
+ self.rejecter = rejecter
135
+ }
136
+
137
+ func onSuccess() {
138
+ resolver([
139
+ "success": true,
140
+ "message": "Login successful"
141
+ ])
142
+ }
143
+
144
+ func onFailure(_ error: AdchainLoginError) {
145
+ rejecter("LOGIN_ERROR", error.description, nil)
146
+ }
147
+ }
148
+
149
+ AdchainSdk.shared.login(adchainSdkUser: user, listener: LoginListenerImpl(resolver: resolver, rejecter: rejecter))
150
+ }
151
+
152
+ @objc func logout(_ resolver: @escaping RCTPromiseResolveBlock,
153
+ rejecter: @escaping RCTPromiseRejectBlock) {
154
+ AdchainSdk.shared.logout()
155
+ resolver([
156
+ "success": true,
157
+ "message": "Logout successful"
158
+ ])
159
+ }
160
+
161
+ @objc func isLoggedIn(_ resolver: @escaping RCTPromiseResolveBlock,
162
+ rejecter: @escaping RCTPromiseRejectBlock) {
163
+ resolver(AdchainSdk.shared.isLoggedIn)
164
+ }
165
+
166
+ @objc func getCurrentUser(_ resolver: @escaping RCTPromiseResolveBlock,
167
+ rejecter: @escaping RCTPromiseRejectBlock) {
168
+ if let user = AdchainSdk.shared.getCurrentUser() {
169
+ var userDict: [String: Any] = ["userId": user.userId]
170
+
171
+ if let gender = user.gender {
172
+ userDict["gender"] = gender.rawValue
173
+ }
174
+
175
+ if let birthYear = user.birthYear {
176
+ userDict["birthYear"] = birthYear
177
+ }
178
+
179
+ resolver(userDict)
180
+ } else {
181
+ resolver(nil)
182
+ }
183
+ }
184
+
185
+ // MARK: - 3. Quiz 관련 (2개)
186
+
187
+ @objc func loadQuizList(_ unitId: NSString,
188
+ resolver: @escaping RCTPromiseResolveBlock,
189
+ rejecter: @escaping RCTPromiseRejectBlock) {
190
+ // 인스턴스 자동 생성/재사용
191
+ let quiz: AdchainQuiz = quizInstances[unitId as String] ?? {
192
+ let newQuiz = AdchainQuiz()
193
+ quizInstances[unitId as String] = newQuiz
194
+ return newQuiz
195
+ }()
196
+
197
+ // Android와 동일하게 직접 load 호출
198
+ // shouldStoreCallbacks: false로 설정하여 refreshAfterCompletion에서 재호출되지 않도록 함
199
+ quiz.load(
200
+ onSuccess: { quizResponse in
201
+ var responseDict: [String: Any] = [
202
+ "success": quizResponse.success ?? true
203
+ ]
204
+
205
+ if let titleText = quizResponse.titleText {
206
+ responseDict["titleText"] = titleText
207
+ }
208
+ if let completedImageUrl = quizResponse.completedImageUrl {
209
+ responseDict["completedImageUrl"] = completedImageUrl
210
+ }
211
+ if let completedImageWidth = quizResponse.completedImageWidth {
212
+ responseDict["completedImageWidth"] = completedImageWidth
213
+ }
214
+ if let completedImageHeight = quizResponse.completedImageHeight {
215
+ responseDict["completedImageHeight"] = completedImageHeight
216
+ }
217
+ if let message = quizResponse.message {
218
+ responseDict["message"] = message
219
+ }
220
+
221
+ // events 배열 처리
222
+ let eventsArray = quizResponse.events.map { quizEvent in
223
+ return [
224
+ "id": quizEvent.id,
225
+ "title": quizEvent.title,
226
+ "description": quizEvent.description ?? "",
227
+ "imageUrl": quizEvent.imageUrl,
228
+ "point": quizEvent.point,
229
+ "isCompleted": quizEvent.completed ?? false
230
+ ]
231
+ }
232
+ responseDict["events"] = eventsArray
233
+
234
+ resolver(responseDict)
235
+ },
236
+ onFailure: { error in
237
+ rejecter("QUIZ_LOAD_ERROR", error.localizedDescription, nil)
238
+ },
239
+ shouldStoreCallbacks: false
240
+ )
241
+ }
242
+
243
+ @objc func clickQuiz(_ unitId: NSString,
244
+ quizId: NSString,
245
+ resolver: @escaping RCTPromiseResolveBlock,
246
+ rejecter: @escaping RCTPromiseRejectBlock) {
247
+ // 인스턴스 자동 생성/재사용
248
+ let quiz: AdchainQuiz = quizInstances[unitId as String] ?? {
249
+ let newQuiz = AdchainQuiz()
250
+ quizInstances[unitId as String] = newQuiz
251
+ return newQuiz
252
+ }()
253
+
254
+ // QuizEventsListener 설정
255
+ class QuizEventListenerImpl: NSObject, AdchainQuizEventsListener {
256
+ weak var module: AdchainSdkModule?
257
+ let unitId: String
258
+ let quizId: String
259
+
260
+ init(module: AdchainSdkModule?, unitId: String, quizId: String) {
261
+ self.module = module
262
+ self.unitId = unitId
263
+ self.quizId = quizId
264
+ }
265
+
266
+ func onImpressed(_ quizEvent: QuizEvent) {
267
+ // 필요시 처리
268
+ }
269
+
270
+ func onClicked(_ quizEvent: QuizEvent) {
271
+ // 필요시 처리
272
+ }
273
+
274
+ func onQuizCompleted(_ quizEvent: QuizEvent, rewardAmount: Int) {
275
+ // React Native로 이벤트 전송
276
+ module?.sendEvent(withName: "onQuizCompleted", body: [
277
+ "unitId": unitId,
278
+ "quizId": quizId,
279
+ "rewardAmount": rewardAmount,
280
+ "timestamp": Date().timeIntervalSince1970
281
+ ])
282
+ }
283
+ }
284
+
285
+ let listener = QuizEventListenerImpl(module: self, unitId: unitId as String, quizId: quizId as String)
286
+ quiz.setQuizEventsListener(listener)
287
+
288
+ // iOS SDK가 ID 기반 클릭을 지원한다고 가정
289
+ DispatchQueue.main.async { [weak self] in
290
+ if let topVC = self?.getTopViewController() {
291
+ // iOS SDK의 네이티브 ID 기반 메서드 사용 (Android와 동일)
292
+ // 만약 이 메서드가 없다면 컴파일 오류가 발생할 것
293
+ quiz.clickQuiz(quizId as String, from: topVC)
294
+ resolver([
295
+ "success": true,
296
+ "message": "Quiz clicked"
297
+ ])
298
+ } else {
299
+ rejecter("QUIZ_ERROR", "No view controller available", nil)
300
+ }
301
+ }
302
+ }
303
+
304
+ // MARK: - 4. Mission 관련 (3개)
305
+
306
+ @objc func loadMissionList(_ unitId: NSString,
307
+ resolver: @escaping RCTPromiseResolveBlock,
308
+ rejecter: @escaping RCTPromiseRejectBlock) {
309
+ // 인스턴스 자동 생성/재사용
310
+ let mission: AdchainMission = missionInstances[unitId as String] ?? {
311
+ let newMission = AdchainMission()
312
+ missionInstances[unitId as String] = newMission
313
+ return newMission
314
+ }()
315
+
316
+ // MissionEventsListener 설정 (missionRefreshed 이벤트를 받기 위해)
317
+ // eventsListener가 없으면 설정
318
+ if mission.eventsListener == nil {
319
+ class LoadMissionEventListenerImpl: NSObject, AdchainMissionEventsListener {
320
+ weak var module: AdchainSdkModule?
321
+ let unitId: String
322
+
323
+ init(module: AdchainSdkModule?, unitId: String) {
324
+ self.module = module
325
+ self.unitId = unitId
326
+ }
327
+
328
+ func onImpressed(_ mission: Mission) {
329
+ // 필요시 처리
330
+ }
331
+
332
+ func onClicked(_ mission: Mission) {
333
+ // 필요시 처리
334
+ }
335
+
336
+ func onCompleted(_ mission: Mission) {
337
+ // React Native로 이벤트 전송
338
+ module?.sendEvent(withName: "onMissionCompleted", body: [
339
+ "unitId": unitId,
340
+ "missionId": mission.id,
341
+ "timestamp": Date().timeIntervalSince1970
342
+ ])
343
+ }
344
+
345
+ func onProgressed(_ mission: Mission) {
346
+ // React Native로 이벤트 전송
347
+ module?.sendEvent(withName: "onMissionProgressed", body: [
348
+ "unitId": unitId,
349
+ "missionId": mission.id,
350
+ "timestamp": Date().timeIntervalSince1970
351
+ ])
352
+ }
353
+
354
+ func onRefreshed(unitId: String?) {
355
+ // React Native로 이벤트 전송
356
+ module?.sendEvent(withName: "onMissionRefreshed", body: [
357
+ "unitId": unitId ?? self.unitId,
358
+ "timestamp": Date().timeIntervalSince1970
359
+ ])
360
+ }
361
+ }
362
+
363
+ let listener = LoadMissionEventListenerImpl(module: self, unitId: unitId as String)
364
+ mission.setEventsListener(listener)
365
+ }
366
+
367
+ // Android와 동일하게 직접 load 호출
368
+ // shouldStoreCallbacks: false로 설정하여 refreshAfterCompletion에서 재호출되지 않도록 함
369
+ mission.load(
370
+ onSuccess: { (missionList, progress) in
371
+ let missionsArray = missionList.map { m in
372
+ return [
373
+ "id": m.id,
374
+ "title": m.title,
375
+ "description": m.description,
376
+ "imageUrl": m.imageUrl,
377
+ "point": m.point,
378
+ "isCompleted": m.status == "completed",
379
+ "type": m.type?.rawValue ?? "normal",
380
+ "actionUrl": m.landingUrl
381
+ ]
382
+ }
383
+
384
+ let completedCount = progress.current
385
+ let totalCount = progress.total
386
+
387
+ let result: [String: Any] = [
388
+ "missions": missionsArray,
389
+ "completedCount": completedCount,
390
+ "totalCount": totalCount,
391
+ "canClaimReward": progress.isCompleted && totalCount > 0,
392
+
393
+ // 신규 필드 추가 (MissionResponse에서 가져오기)
394
+ "titleText": mission.missionResponse?.titleText ?? "무료 포인트 모으기!",
395
+ "descriptionText": mission.missionResponse?.descriptionText ?? "간단 광고 참여하고 100 포인트 받기",
396
+ "bottomText": mission.missionResponse?.bottomText ?? "800만 포인트 받으러 가기",
397
+ "rewardIconUrl": mission.missionResponse?.rewardIconUrl ?? "https://adchain-assets.1self.world/img_reward_coin.png",
398
+ "bottomIconUrl": mission.missionResponse?.bottomIconUrl ?? "https://adchain-assets.1self.world/img_offerwall_coin.png"
399
+ ]
400
+
401
+ resolver(result)
402
+ },
403
+ onFailure: { error in
404
+ rejecter("MISSION_LOAD_ERROR", error.localizedDescription, nil)
405
+ },
406
+ shouldStoreCallbacks: false
407
+ )
408
+ }
409
+
410
+ @objc func clickMission(_ unitId: NSString,
411
+ missionId: NSString,
412
+ resolver: @escaping RCTPromiseResolveBlock,
413
+ rejecter: @escaping RCTPromiseRejectBlock) {
414
+ // 인스턴스 자동 생성/재사용
415
+ let mission: AdchainMission = missionInstances[unitId as String] ?? {
416
+ let newMission = AdchainMission()
417
+ missionInstances[unitId as String] = newMission
418
+ return newMission
419
+ }()
420
+
421
+ // MissionEventsListener 설정
422
+ class MissionEventListenerImpl: NSObject, AdchainMissionEventsListener {
423
+ weak var module: AdchainSdkModule?
424
+ let unitId: String
425
+ let missionId: String
426
+
427
+ init(module: AdchainSdkModule?, unitId: String, missionId: String) {
428
+ self.module = module
429
+ self.unitId = unitId
430
+ self.missionId = missionId
431
+ }
432
+
433
+ func onImpressed(_ mission: Mission) {
434
+ // 필요시 처리
435
+ }
436
+
437
+ func onClicked(_ mission: Mission) {
438
+ // 필요시 처리
439
+ }
440
+
441
+ func onCompleted(_ mission: Mission) {
442
+ // React Native로 이벤트 전송
443
+ module?.sendEvent(withName: "onMissionCompleted", body: [
444
+ "unitId": unitId,
445
+ "missionId": missionId,
446
+ "timestamp": Date().timeIntervalSince1970
447
+ ])
448
+ }
449
+
450
+ func onProgressed(_ mission: Mission) {
451
+ // React Native로 이벤트 전송 (missionCompleted와 동일한 구조)
452
+ module?.sendEvent(withName: "onMissionProgressed", body: [
453
+ "unitId": unitId,
454
+ "missionId": missionId,
455
+ "timestamp": Date().timeIntervalSince1970
456
+ ])
457
+ }
458
+
459
+ func onRefreshed(unitId: String?) {
460
+ // React Native로 이벤트 전송
461
+ module?.sendEvent(withName: "onMissionRefreshed", body: [
462
+ "unitId": unitId ?? self.unitId,
463
+ "timestamp": Date().timeIntervalSince1970
464
+ ])
465
+ }
466
+ }
467
+
468
+ let listener = MissionEventListenerImpl(module: self, unitId: unitId as String, missionId: missionId as String)
469
+ mission.setEventsListener(listener)
470
+
471
+ // iOS SDK가 ID 기반 클릭을 지원한다고 가정
472
+ DispatchQueue.main.async { [weak self] in
473
+ if let topVC = self?.getTopViewController() {
474
+ // iOS SDK의 네이티브 ID 기반 메서드 사용 (Android와 동일)
475
+ // 만약 이 메서드가 없다면 컴파일 오류가 발생할 것
476
+ mission.clickMission(missionId as String, from: topVC)
477
+ resolver([
478
+ "success": true,
479
+ "message": "Mission clicked"
480
+ ])
481
+ } else {
482
+ rejecter("MISSION_ERROR", "No view controller available", nil)
483
+ }
484
+ }
485
+ }
486
+
487
+ @objc func claimReward(_ unitId: NSString,
488
+ resolver: @escaping RCTPromiseResolveBlock,
489
+ rejecter: @escaping RCTPromiseRejectBlock) {
490
+ // 인스턴스 자동 생성/재사용
491
+ let mission: AdchainMission = missionInstances[unitId as String] ?? {
492
+ let newMission = AdchainMission()
493
+ missionInstances[unitId as String] = newMission
494
+ return newMission
495
+ }()
496
+
497
+ DispatchQueue.main.async { [weak self] in
498
+ if let topVC = self?.getTopViewController() {
499
+ mission.onRewardButtonClicked(from: topVC)
500
+ resolver([
501
+ "success": true,
502
+ "message": "Reward claimed"
503
+ ])
504
+ } else {
505
+ rejecter("MISSION_ERROR", "No view controller available", nil)
506
+ }
507
+ }
508
+ }
509
+
510
+ // MARK: - 5. Debug/Utility Methods (3개)
511
+
512
+ @objc func isInitialized(_ resolver: @escaping RCTPromiseResolveBlock,
513
+ rejecter: @escaping RCTPromiseRejectBlock) {
514
+ resolver(AdchainSdk.shared.isInitialized())
515
+ }
516
+
517
+ @objc func getUserId(_ resolver: @escaping RCTPromiseResolveBlock,
518
+ rejecter: @escaping RCTPromiseRejectBlock) {
519
+ let userId = AdchainSdk.shared.getCurrentUser()?.userId ?? ""
520
+ resolver(userId)
521
+ }
522
+
523
+ @objc func getIFA(_ resolver: @escaping RCTPromiseResolveBlock,
524
+ rejecter: @escaping RCTPromiseRejectBlock) {
525
+ // SDK의 getAdvertisingId 메서드를 사용하여 IFA 가져오기
526
+ // SDK가 캐싱, 권한 처리, 에러 처리 등을 모두 관리함
527
+ AdchainSdk.shared.getAdvertisingId { advertisingId in
528
+ resolver(advertisingId)
529
+ }
530
+ }
531
+
532
+ // MARK: - 6. Banner (1개)
533
+
534
+ @objc func getBannerInfo(_ placementId: String,
535
+ resolver: @escaping RCTPromiseResolveBlock,
536
+ rejecter: @escaping RCTPromiseRejectBlock) {
537
+ AdchainSdk.shared.getBannerInfo(placementId: placementId) { result in
538
+ switch result {
539
+ case .success(let response):
540
+ resolver([
541
+ "success": response.success,
542
+ "imageUrl": response.imageUrl as Any,
543
+ "imageWidth": response.imageWidth as Any,
544
+ "imageHeight": response.imageHeight as Any,
545
+ "titleText": response.titleText as Any,
546
+ "linkType": response.linkType as Any,
547
+ "internalLinkUrl": response.internalLinkUrl as Any,
548
+ "externalLinkUrl": response.externalLinkUrl as Any
549
+ ])
550
+ case .failure(let error):
551
+ rejecter("BANNER_ERROR", error.localizedDescription, error)
552
+ }
553
+ }
554
+ }
555
+
556
+ // MARK: - 7. Offerwall (3개)
557
+
558
+ @objc func openOfferwall(_ placementId: NSString?,
559
+ resolver: @escaping RCTPromiseResolveBlock,
560
+ rejecter: @escaping RCTPromiseRejectBlock) {
561
+ class OfferwallCallbackImpl: NSObject, OfferwallCallback {
562
+ let resolver: RCTPromiseResolveBlock
563
+ var hasResolved = false
564
+
565
+ init(resolver: @escaping RCTPromiseResolveBlock) {
566
+ self.resolver = resolver
567
+ }
568
+
569
+ func onOpened() {
570
+ if !hasResolved {
571
+ hasResolved = true
572
+ resolver([
573
+ "success": true,
574
+ "message": "Offerwall opened"
575
+ ])
576
+ }
577
+ }
578
+
579
+ func onClosed() {
580
+ // 이미 resolve 되었으므로 무시
581
+ }
582
+
583
+ func onError(_ message: String) {
584
+ // 이미 resolve 되었으므로 무시
585
+ }
586
+
587
+ func onRewardEarned(_ amount: Int) {
588
+ // 이미 resolve 되었으므로 무시
589
+ }
590
+ }
591
+
592
+ class OfferwallEventCallbackImpl: NSObject, OfferwallEventCallback {
593
+ func onCustomEvent(eventType: String, payload: [String : Any]) {
594
+ print("[AdchainSdk] [WebView → App] Custom Event: type=\(eventType), payload=\(payload)")
595
+ // Sample 앱에서 처리하도록 이벤트만 전달 (Alert/Toast 표시 안 함)
596
+ }
597
+
598
+ func onDataRequest(requestId: String, requestType: String, params: [String : Any]) -> [String : Any]? {
599
+ print("[AdchainSdk] [WebView → App] Data Request: type=\(requestType), params=\(params)")
600
+
601
+ // Return mock data based on request type
602
+ let response: [String: Any]?
603
+ switch requestType {
604
+ case "user_points":
605
+ response = ["points": 12345, "currency": "KRW"]
606
+ case "user_profile":
607
+ response = ["userId": "test_123", "nickname": "TestPlayer", "level": 42]
608
+ case "app_version":
609
+ response = ["version": "1.0.0", "buildNumber": 100]
610
+ default:
611
+ response = nil
612
+ }
613
+
614
+ print("[AdchainSdk] [App → WebView] Data Response: \(response ?? [:])")
615
+ return response
616
+ }
617
+ }
618
+
619
+ DispatchQueue.main.async { [weak self] in
620
+ if let topVC = self?.getTopViewController() {
621
+ let callback = OfferwallCallbackImpl(resolver: resolver)
622
+ let eventCallback = OfferwallEventCallbackImpl()
623
+ let finalPlacementId = (placementId as String?) ?? ""
624
+ AdchainSdk.shared.openOfferwall(
625
+ presentingViewController: topVC,
626
+ placementId: finalPlacementId,
627
+ callback: callback,
628
+ eventCallback: eventCallback
629
+ )
630
+ } else {
631
+ rejecter("OFFERWALL_ERROR", "No view controller available", nil)
632
+ }
633
+ }
634
+ }
635
+
636
+ @objc func openOfferwallWithUrl(_ url: NSString,
637
+ placementId: NSString?,
638
+ resolver: @escaping RCTPromiseResolveBlock,
639
+ rejecter: @escaping RCTPromiseRejectBlock) {
640
+ class OfferwallCallbackImpl: NSObject, OfferwallCallback {
641
+ let resolver: RCTPromiseResolveBlock
642
+ var hasResolved = false
643
+
644
+ init(resolver: @escaping RCTPromiseResolveBlock) {
645
+ self.resolver = resolver
646
+ }
647
+
648
+ func onOpened() {
649
+ if !hasResolved {
650
+ hasResolved = true
651
+ resolver([
652
+ "success": true,
653
+ "message": "Offerwall opened with URL"
654
+ ])
655
+ }
656
+ }
657
+
658
+ func onClosed() {
659
+ // 이미 resolve 되었으므로 무시
660
+ }
661
+
662
+ func onError(_ message: String) {
663
+ // 이미 resolve 되었으므로 무시
664
+ }
665
+
666
+ func onRewardEarned(_ amount: Int) {
667
+ // 이미 resolve 되었으므로 무시
668
+ }
669
+ }
670
+
671
+ class OfferwallEventCallbackImpl: NSObject, OfferwallEventCallback {
672
+ func onCustomEvent(eventType: String, payload: [String : Any]) {
673
+ print("[AdchainSdk] [WebView → App] Custom Event: type=\(eventType), payload=\(payload)")
674
+ // Sample 앱에서 처리하도록 이벤트만 전달 (Alert/Toast 표시 안 함)
675
+ }
676
+
677
+ func onDataRequest(requestId: String, requestType: String, params: [String : Any]) -> [String : Any]? {
678
+ print("[AdchainSdk] [WebView → App] Data Request: type=\(requestType), params=\(params)")
679
+
680
+ // Return mock data based on request type
681
+ let response: [String: Any]?
682
+ switch requestType {
683
+ case "user_points":
684
+ response = ["points": 12345, "currency": "KRW"]
685
+ case "user_profile":
686
+ response = ["userId": "test_123", "nickname": "TestPlayer", "level": 42]
687
+ case "app_version":
688
+ response = ["version": "1.0.0", "buildNumber": 100]
689
+ default:
690
+ response = nil
691
+ }
692
+
693
+ print("[AdchainSdk] [App → WebView] Data Response: \(response ?? [:])")
694
+ return response
695
+ }
696
+ }
697
+
698
+ DispatchQueue.main.async { [weak self] in
699
+ if let topVC = self?.getTopViewController() {
700
+ let callback = OfferwallCallbackImpl(resolver: resolver)
701
+ let eventCallback = OfferwallEventCallbackImpl()
702
+ let finalPlacementId = (placementId as String?) ?? ""
703
+ AdchainSdk.shared.openOfferwallWithUrl(
704
+ url as String,
705
+ placementId: finalPlacementId,
706
+ presentingViewController: topVC,
707
+ callback: callback,
708
+ eventCallback: eventCallback
709
+ )
710
+ } else {
711
+ rejecter("OFFERWALL_ERROR", "No view controller available", nil)
712
+ }
713
+ }
714
+ }
715
+
716
+ @objc func openExternalBrowser(_ url: NSString,
717
+ placementId: NSString?,
718
+ resolver: @escaping RCTPromiseResolveBlock,
719
+ rejecter: @escaping RCTPromiseRejectBlock) {
720
+ let finalPlacementId = (placementId as String?) ?? ""
721
+ let success = AdchainSdk.shared.openExternalBrowser(
722
+ url as String,
723
+ placementId: finalPlacementId
724
+ )
725
+ if success {
726
+ resolver([
727
+ "success": true,
728
+ "message": "External browser opened"
729
+ ])
730
+ } else {
731
+ rejecter("BROWSER_ERROR", "Failed to open external browser", nil)
732
+ }
733
+ }
734
+
735
+ @objc func openAdjoeOfferwall(_ placementId: NSString?,
736
+ resolver: @escaping RCTPromiseResolveBlock,
737
+ rejecter: @escaping RCTPromiseRejectBlock) {
738
+ class OfferwallCallbackImpl: NSObject, OfferwallCallback {
739
+ let resolver: RCTPromiseResolveBlock
740
+ var hasResolved = false
741
+
742
+ init(resolver: @escaping RCTPromiseResolveBlock) {
743
+ self.resolver = resolver
744
+ }
745
+
746
+ func onOpened() {
747
+ if !hasResolved {
748
+ hasResolved = true
749
+ resolver([
750
+ "success": true,
751
+ "message": "Adjoe Offerwall opened"
752
+ ])
753
+ }
754
+ }
755
+
756
+ func onClosed() {
757
+ // 이미 resolve 되었으므로 무시
758
+ }
759
+
760
+ func onError(_ message: String) {
761
+ // 이미 resolve 되었으므로 무시
762
+ }
763
+
764
+ func onRewardEarned(_ amount: Int) {
765
+ // 이미 resolve 되었으므로 무시
766
+ }
767
+ }
768
+
769
+ DispatchQueue.main.async { [weak self] in
770
+ if let topVC = self?.getTopViewController() {
771
+ let callback = OfferwallCallbackImpl(resolver: resolver)
772
+ let finalPlacementId = (placementId as String?) ?? ""
773
+ AdchainSdk.shared.openAdjoeOfferwall(
774
+ presentingViewController: topVC,
775
+ placementId: finalPlacementId,
776
+ callback: callback
777
+ )
778
+ } else {
779
+ rejecter("ADJOE_ERROR", "No view controller available", nil)
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ // MARK: - Objective-C Export
786
+
787
+ @objc(AdchainSdk)
788
+ extension AdchainSdkModule {
789
+ @objc override static func moduleName() -> String! {
790
+ return "AdchainSdk"
791
+ }
792
+ }