From b7897533dbe12d0b5697802dbc2a3e6f9d653349 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 13 Oct 2026 21:04:37 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EA=B4=91=EA=B3=A0=20=EC=8B=9C?= =?UTF-8?q?=EC=B2=AD=20Pro=20=EC=9D=B4=EC=9A=A9=EA=B6=8C,=20=EC=95=B1=20?= =?UTF-8?q?=EC=98=A4=ED=94=84=EB=8B=9D=20=EA=B4=91=EA=B3=A0,=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=97=B0=EA=B2=B0=20=EC=8B=9C=20?= =?UTF-8?q?Pro=20=ED=8C=9D=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보상형 광고를 끝까지 보면 3시간 동안 Pro 기능(백그라운드 충전 알림, Live Activity, 광고 제거)을 연다. 유료 기능 판정은 PremiumAccess.isActive(구독 ‖ 이용권)로 모은다 - 백그라운드에서 돌아올 때 앱 오프닝 광고를 띄운다. 마지막 광고 후 15분 간격, 약관 링크·광고 클릭 직후 1회 건너뜀 - 앱 실행 후 첫 컨트롤러 연결 때 Pro 소개 팝업(광고 이용권 / 구독 보기 / 나중에) - 앱 시작 시 알림 권한 → UMP 동의 → ATT 순서로 묻는다(AdConsentManager) - Google Mobile Ads 13.x, UMP 3.x(SPM), SKAdNetwork ID, ATT 설명 문구 추가 - 구매 화면 하단에 광고 이용권 버튼, 약관 링크가 가격 영역에 가려지던 레이아웃 수정 Co-Authored-By: Claude Opus 5.5 --- .../Controllers.xcodeproj/project.pbxproj | 52 +++++ .../xcshareddata/swiftpm/Package.resolved | 21 +- .../Applications/AppDelegate.swift | 27 ++- .../Applications/SceneDelegate.swift | 46 +++- .../Sources/Controller/ControllerModel.swift | 11 +- .../Sources/Extensions/StringKey+.swift | 2 + .../Sources/Models/AdConsentManager.swift | 80 +++++++ .../Sources/Models/AppOpenAdManager.swift | 136 ++++++++++++ .../Sources/Models/LiveActivityManager.swift | 11 +- .../Sources/Models/ProPromotionManager.swift | 98 +++++++++ .../Sources/Models/RewardedPassManager.swift | 164 ++++++++++++++ .../Setting/IAPOnboardingViewController.swift | 109 ++++++++- .../Setting/SettingViewController.swift | 11 + .../Controllers/Supporting Files/Info.plist | 207 ++++++++++++++++++ .../en.lproj/InfoPlist.strings | 3 + .../en.lproj/Localizable.strings | 15 ++ .../ja.lproj/InfoPlist.strings | 3 + .../ja.lproj/Localizable.strings | 15 ++ .../ko.lproj/InfoPlist.strings | 3 + .../ko.lproj/Localizable.strings | 15 ++ 20 files changed, 1004 insertions(+), 25 deletions(-) create mode 100644 Controllers/Controllers/Sources/Models/AdConsentManager.swift create mode 100644 Controllers/Controllers/Sources/Models/AppOpenAdManager.swift create mode 100644 Controllers/Controllers/Sources/Models/ProPromotionManager.swift create mode 100644 Controllers/Controllers/Sources/Models/RewardedPassManager.swift diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index 910b48d..9bbf8ae 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + ADADADAD000000000000C004 /* AdConsentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C003 /* AdConsentManager.swift */; }; + ADADADAD000000000000C006 /* AppOpenAdManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C005 /* AppOpenAdManager.swift */; }; + ADADADAD000000000000C008 /* ProPromotionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C007 /* ProPromotionManager.swift */; }; + ADADADAD000000000000C002 /* RewardedPassManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADADADAD000000000000C001 /* RewardedPassManager.swift */; }; + ADADADAD000000000000D003 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = ADADADAD000000000000D002 /* GoogleMobileAds */; }; + ADADADAD000000000000D013 /* GoogleUserMessagingPlatform in Frameworks */ = {isa = PBXBuildFile; productRef = ADADADAD000000000000D012 /* GoogleUserMessagingPlatform */; }; 369B340A2A4C515300DBCC29 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B34082A4C511E00DBCC29 /* ProgressBar.swift */; }; 369B340C2A4D13B300DBCC29 /* StringKey+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B340B2A4D13B300DBCC29 /* StringKey+.swift */; }; ACACACAC000000000000A002 /* ControllerActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACACACAC000000000000A001 /* ControllerActivityAttributes.swift */; }; @@ -104,6 +110,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + ADADADAD000000000000C003 /* AdConsentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdConsentManager.swift; sourceTree = ""; }; + ADADADAD000000000000C005 /* AppOpenAdManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppOpenAdManager.swift; sourceTree = ""; }; + ADADADAD000000000000C007 /* ProPromotionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProPromotionManager.swift; sourceTree = ""; }; + ADADADAD000000000000C001 /* RewardedPassManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardedPassManager.swift; sourceTree = ""; }; 369B34082A4C511E00DBCC29 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; 369B340B2A4D13B300DBCC29 /* StringKey+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StringKey+.swift"; sourceTree = ""; }; ACACACAC000000000000A001 /* ControllerActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerActivityAttributes.swift; sourceTree = ""; }; @@ -190,6 +200,8 @@ files = ( B5CDDBB72BE7E793007610BC /* SwiftyStoreKit in Frameworks */, B5E2A1CC2DDF734100ED4327 /* ControllerKit in Frameworks */, + ADADADAD000000000000D003 /* GoogleMobileAds in Frameworks */, + ADADADAD000000000000D013 /* GoogleUserMessagingPlatform in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -383,6 +395,10 @@ B55974352ACDC33100FC01FE /* ProductIdentifiers.swift */, ACACACAC000000000000A001 /* ControllerActivityAttributes.swift */, ACACACAC000000000000B001 /* LiveActivityManager.swift */, + ADADADAD000000000000C001 /* RewardedPassManager.swift */, + ADADADAD000000000000C003 /* AdConsentManager.swift */, + ADADADAD000000000000C005 /* AppOpenAdManager.swift */, + ADADADAD000000000000C007 /* ProPromotionManager.swift */, ); path = Models; sourceTree = ""; @@ -451,6 +467,8 @@ packageProductDependencies = ( B5CDDBB62BE7E793007610BC /* SwiftyStoreKit */, B5E2A1CB2DDF734100ED4327 /* ControllerKit */, + ADADADAD000000000000D002 /* GoogleMobileAds */, + ADADADAD000000000000D012 /* GoogleUserMessagingPlatform */, ); productName = Controllers; productReference = EF789B6E2A24D6DC006EC6D8 /* Controllers.app */; @@ -533,6 +551,8 @@ packageReferences = ( B5CDDBB52BE7E793007610BC /* XCRemoteSwiftPackageReference "SwiftyStoreKit" */, B5E2A1CA2DDF733600ED4327 /* XCLocalSwiftPackageReference "ControllerKit" */, + ADADADAD000000000000D001 /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */, + ADADADAD000000000000D011 /* XCRemoteSwiftPackageReference "swift-package-manager-google-user-messaging-platform" */, ); productRefGroup = EF789B6F2A24D6DC006EC6D8 /* Products */; projectDirPath = ""; @@ -629,6 +649,10 @@ B5F403FE2AC126EB00D4A6BD /* FetchGameControllerOperation.swift in Sources */, ACACACAC000000000000A002 /* ControllerActivityAttributes.swift in Sources */, ACACACAC000000000000B002 /* LiveActivityManager.swift in Sources */, + ADADADAD000000000000C002 /* RewardedPassManager.swift in Sources */, + ADADADAD000000000000C004 /* AdConsentManager.swift in Sources */, + ADADADAD000000000000C006 /* AppOpenAdManager.swift in Sources */, + ADADADAD000000000000C008 /* ProPromotionManager.swift in Sources */, EF38A0972A4C745A00DF1AF9 /* UserDefaults+.swift in Sources */, B5E3BDC72DCF901F000622FD /* ProgressBarView.swift in Sources */, B5C7FAE62DDE24C0009F6BD1 /* Bundle+.swift in Sources */, @@ -866,6 +890,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.1.0; + OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1013,6 +1038,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.1.0; + OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1140,6 +1166,22 @@ minimumVersion = 0.16.4; }; }; + ADADADAD000000000000D001 /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 13.10.0; + }; + }; + ADADADAD000000000000D011 /* XCRemoteSwiftPackageReference "swift-package-manager-google-user-messaging-platform" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1158,6 +1200,16 @@ package = B5E2A1CA2DDF733600ED4327 /* XCLocalSwiftPackageReference "ControllerKit" */; productName = ControllerKit; }; + ADADADAD000000000000D002 /* GoogleMobileAds */ = { + isa = XCSwiftPackageProductDependency; + package = ADADADAD000000000000D001 /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */; + productName = GoogleMobileAds; + }; + ADADADAD000000000000D012 /* GoogleUserMessagingPlatform */ = { + isa = XCSwiftPackageProductDependency; + package = ADADADAD000000000000D011 /* XCRemoteSwiftPackageReference "swift-package-manager-google-user-messaging-platform" */; + productName = GoogleUserMessagingPlatform; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = EF789B662A24D6DC006EC6D8 /* Project object */; diff --git a/Controllers/Controllers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Controllers/Controllers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8d37b57..cae5bb8 100644 --- a/Controllers/Controllers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Controllers/Controllers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,24 @@ { + "originHash" : "3eb1190ecdcfa069124addf3eff794f1c3ae48b76d1fbbda7b93b799075da43e", "pins" : [ + { + "identity" : "swift-package-manager-google-mobile-ads", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads.git", + "state" : { + "revision" : "d1fa1f5c1dfe3b43d56c73f4de388ad3316618bc", + "version" : "13.11.0" + } + }, + { + "identity" : "swift-package-manager-google-user-messaging-platform", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git", + "state" : { + "revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b", + "version" : "3.1.0" + } + }, { "identity" : "swiftystorekit", "kind" : "remoteSourceControl", @@ -10,5 +29,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Controllers/Controllers/Applications/AppDelegate.swift b/Controllers/Controllers/Applications/AppDelegate.swift index 5f12b6d..11cad9b 100644 --- a/Controllers/Controllers/Applications/AppDelegate.swift +++ b/Controllers/Controllers/Applications/AppDelegate.swift @@ -14,6 +14,8 @@ import SwiftyStoreKit @main class AppDelegate: UIResponder, UIApplicationDelegate { var orientationLock = UIInterfaceOrientationMask.all + /// 알림 권한 팝업에 답했는지. SceneDelegate의 앱 시작 흐름이 이 값을 보고 광고 동의·ATT를 묻는다. + private(set) var isNotificationAuthorizationAnswered = false func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { registBackgroundTask() StoreKitManager.shared.observeTransactionUpdates() @@ -32,7 +34,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - requestNotificationAuthorization() + Task { + await requestNotificationAuthorization() + isNotificationAuthorizationAnswered = true + } let isSubscribed = UserDefaults.standard.value(forKey: StringKey.IS_SUBSCRIBED) as? Bool return true @@ -59,15 +64,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Push Notifications Method extension AppDelegate { - private func requestNotificationAuthorization() { - + /// 사용자가 답할 때까지 기다린다. 이미 답했으면 바로 끝난다. + private func requestNotificationAuthorization() async { let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if let error = error { - print(error.localizedDescription) - } - + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) print("Permission granted: \(granted)") + } catch { + print(error.localizedDescription) } } } @@ -81,9 +85,12 @@ extension AppDelegate { BGTaskScheduler.shared.register(forTaskWithIdentifier: StringKey.BATTERY_IDENTIFIER, using: nil) { task in // Perform your background fetch here - print(#function, "isSubscribed:", StoreKitManager.shared.isSubscribed) - if StoreKitManager.shared.isSubscribed { + print(#function, "isPremiumActive:", PremiumAccess.isActive) + if PremiumAccess.isActive { self.handleAppRefreshTask(task: task as! BGAppRefreshTask) + } else { + // 광고 이용권이 끝났거나 구독이 만료됐다. 할 일 없이 바로 끝낸다. + task.setTaskCompleted(success: true) } } } diff --git a/Controllers/Controllers/Applications/SceneDelegate.swift b/Controllers/Controllers/Applications/SceneDelegate.swift index ad4a9e3..5f59b0e 100644 --- a/Controllers/Controllers/Applications/SceneDelegate.swift +++ b/Controllers/Controllers/Applications/SceneDelegate.swift @@ -13,6 +13,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private var isBackground = false + private var launchTask: Task? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { @@ -27,8 +28,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidDisconnect(_ scene: UIScene) {} func sceneDidBecomeActive(_ scene: UIScene) { + // 제어 센터·알림 센터를 내렸다 올린 경우는 백그라운드를 거치지 않아 false다. + let isReturningFromBackground = isBackground isBackground = false - Task { await StoreKitManager.shared.refreshSubscriptionStatus() } + if launchTask == nil { + launchTask = Task { await runLaunchFlow() } + } + Task { + await StoreKitManager.shared.refreshSubscriptionStatus() + // 구독이 끝났거나 광고 이용권이 만료됐으면 남아 있는 Live Activity 를 닫는다. + if !PremiumAccess.isActive { + LiveActivityManager.shared.end() + } + if isReturningFromBackground { + AppOpenAdManager.shared.showAdIfAvailable() + } + ProPromotionManager.shared.presentIfNeeded() + } } func sceneWillResignActive(_ scene: UIScene) {} @@ -36,8 +52,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneWillEnterForeground(_ scene: UIScene) {} func sceneDidEnterBackground(_ scene: UIScene) { - print(#function, "isSubscribed:", StoreKitManager.shared.isSubscribed) - if StoreKitManager.shared.isSubscribed { + print(#function, "isPremiumActive:", PremiumAccess.isActive) + if PremiumAccess.isActive { let appDeleagate = UIApplication.shared.delegate as! AppDelegate appDeleagate.scheduleAppRefresh() } @@ -46,3 +62,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } +// MARK: - Launch Flow +extension SceneDelegate { + /// 앱을 켤 때 한 번만 실행한다. 시스템 팝업이 겹치지 않게 차례로 묻는다. + /// 알림 권한 → (구독자가 아니면) 광고 동의·ATT → 앱 오프닝 광고 미리 불러오기 → Pro 팝업 허용 + private func runLaunchFlow() async { + // 알림 권한 팝업에 답할 때까지 기다린다. 팝업이 끝내 뜨지 않을 때를 대비해 최대 10초만 기다린다. + // 더 늦게 답해도 괜찮다. AdConsentManager.prepare()가 팝업이 닫혀 앱이 다시 활성화될 때까지 기다린다. + let appDelegate = UIApplication.shared.delegate as? AppDelegate + var waitCount = 0 + while appDelegate?.isNotificationAuthorizationAnswered == false, waitCount < 20 { + try? await Task.sleep(nanoseconds: 500_000_000) + waitCount += 1 + } + + await StoreKitManager.shared.refreshSubscriptionStatus() + if !StoreKitManager.shared.isSubscribed, + let rootViewController = window?.rootViewController, + await AdConsentManager.shared.prepare(from: rootViewController) { + Task { await AppOpenAdManager.shared.loadAdIfNeeded() } + } + ProPromotionManager.shared.isLaunchFlowFinished = true + } +} + diff --git a/Controllers/Controllers/Sources/Controller/ControllerModel.swift b/Controllers/Controllers/Sources/Controller/ControllerModel.swift index f122b3a..0d64b02 100644 --- a/Controllers/Controllers/Sources/Controller/ControllerModel.swift +++ b/Controllers/Controllers/Sources/Controller/ControllerModel.swift @@ -49,7 +49,7 @@ final class ControllerModel: ObservableObject { // MARK: - Live Activity extension ControllerModel { - /// 컨트롤러 연결 시 배터리 Live Activity 를 시작한다. (프리미엄 구독자 전용) + /// 컨트롤러 연결 시 배터리 Live Activity 를 시작한다. (구독자·광고 이용권 사용자 전용) private func startLiveActivity() { guard let info = manager.getControlelrInfo() else { return } LiveActivityManager.shared.start(vendorName: info.vendorName, @@ -61,6 +61,11 @@ extension ControllerModel { extension ControllerModel { private func addControllerObservers() { manager.addDelegate(self) + // 컨트롤러가 연결된 채로 광고 이용권을 받으면 바로 Live Activity 를 띄운다. + NotificationCenter.default.addObserver(forName: .proPassGranted, object: nil, queue: .main) { [weak self] _ in + guard self?.isConnected == true else { return } + self?.startLiveActivity() + } } } @@ -70,6 +75,10 @@ extension ControllerModel: GameControllerDelegate { isConnected = true updateControllerInfo() startLiveActivity() + // 유료 기능을 쓰지 않는 사용자에게 앱 실행 후 첫 연결 때 Pro 소개 팝업을 띄운다. + Task { @MainActor in + ProPromotionManager.shared.controllerDidConnect() + } } func didDisConnectedController() { diff --git a/Controllers/Controllers/Sources/Extensions/StringKey+.swift b/Controllers/Controllers/Sources/Extensions/StringKey+.swift index 1aa2dcb..f98779d 100644 --- a/Controllers/Controllers/Sources/Extensions/StringKey+.swift +++ b/Controllers/Controllers/Sources/Extensions/StringKey+.swift @@ -16,6 +16,8 @@ final class StringKey { static let BATTERY_IDENTIFIER: String = "com.controller.battery" static let IS_SUBSCRIBED: String = "isSubscribed" + static let PRO_PASS_EXPIRATION: String = "proPassExpiration" + static let LAST_APP_OPEN_AD_DATE: String = "lastAppOpenAdDate" static let BATTERY_NOTIFICATION_THRESHOLD: String = "batteryNotificationThreshold" } diff --git a/Controllers/Controllers/Sources/Models/AdConsentManager.swift b/Controllers/Controllers/Sources/Models/AdConsentManager.swift new file mode 100644 index 0000000..db6e126 --- /dev/null +++ b/Controllers/Controllers/Sources/Models/AdConsentManager.swift @@ -0,0 +1,80 @@ +// +// AdConsentManager.swift +// Controllers +// +// 광고를 요청하기 전에 거치는 단계를 한곳에서 처리한다. +// Google UMP 동의(EU·영국 등) → ATT 추적 허용 → Google Mobile Ads SDK 시작 순서다. +// 앱 시작 흐름(SceneDelegate)과 보상형 광고 버튼에서 부른다. +// + +import UIKit +import AppTrackingTransparency +import GoogleMobileAds +import UserMessagingPlatform + +@MainActor +final class AdConsentManager { + + static let shared = AdConsentManager() + + private var isMobileAdsStarted = false + + private init() {} + + /// 광고를 요청해도 되는지. 이전 실행에서 받은 동의도 반영된다. + var canRequestAds: Bool { + ConsentInformation.shared.canRequestAds + } + + /// EU·영국 등 동의가 필요한 지역이면 true. 이때는 광고 동의를 다시 고르는 진입점을 보여야 한다. + var isPrivacyOptionsRequired: Bool { + ConsentInformation.shared.privacyOptionsRequirementStatus == .required + } + + /// 동의·ATT를 묻고 SDK를 시작한다. 광고를 요청할 수 있으면 true + func prepare(from viewController: UIViewController) async -> Bool { + // 알림 권한 같은 시스템 팝업이 떠 있는 동안은 앱이 비활성이라 동의 화면·ATT 팝업이 뜨지 않는다. + await waitUntilActive() + + let consentInformation = ConsentInformation.shared + do { + try await consentInformation.requestConsentInfoUpdate(with: nil) + try await ConsentForm.loadAndPresentIfRequired(from: viewController) + } catch { + // 갱신에 실패해도 이전 실행에서 받은 동의가 있으면 광고를 요청할 수 있다. + NSLog("광고 동의 정보 갱신 실패: \(error.localizedDescription)") + } + guard consentInformation.canRequestAds else { return false } + + // 허용 여부와 관계없이 보상은 같다. 허용하면 맞춤 광고가 나간다. + if ATTrackingManager.trackingAuthorizationStatus == .notDetermined { + await waitUntilActive() + _ = await ATTrackingManager.requestTrackingAuthorization() + } + + await startMobileAdsIfNeeded() + return true + } + + func startMobileAdsIfNeeded() async { + guard !isMobileAdsStarted else { return } + isMobileAdsStarted = true + _ = await MobileAds.shared.start() + } + + /// 사용자가 광고 동의를 다시 고르는 Google 화면 + func presentPrivacyOptions(from viewController: UIViewController) async { + do { + try await ConsentForm.presentPrivacyOptionsForm(from: viewController) + } catch { + NSLog("광고 동의 설정 화면 표시 실패: \(error.localizedDescription)") + } + } + + private func waitUntilActive() async { + guard UIApplication.shared.applicationState != .active else { return } + for await _ in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) { + return + } + } +} diff --git a/Controllers/Controllers/Sources/Models/AppOpenAdManager.swift b/Controllers/Controllers/Sources/Models/AppOpenAdManager.swift new file mode 100644 index 0000000..b0733d5 --- /dev/null +++ b/Controllers/Controllers/Sources/Models/AppOpenAdManager.swift @@ -0,0 +1,136 @@ +// +// AppOpenAdManager.swift +// Controllers +// +// 유료 기능을 쓰지 않는 사용자가 백그라운드에서 앱으로 돌아오면 앱 오프닝 광고를 띄운다. +// AdMob 정책상 앱을 열거나 돌아오는 시점에는 전면 광고를 쓸 수 없어 이 형식을 쓴다. +// + +import UIKit +import GoogleMobileAds + +final class AppOpenAdManager: NSObject { + + static let shared = AppOpenAdManager() + + /// 마지막으로 광고를 띄운 뒤 이 시간이 지나야 다시 띄운다. + static let minimumInterval: TimeInterval = 15 * 60 + + /// 불러온 광고는 4시간이 지나면 띄워도 수익이 나지 않는다. (Google 권장) + private static let adLifetime: TimeInterval = 4 * 60 * 60 + + #if DEBUG + /// Google 테스트 앱 오프닝 광고 단위. 개발 중에는 실제 광고 단위를 쓰지 않는다. + private let adUnitID = "ca-app-pub-3940256099942544/5575463023" + #else + /// AdMob '아차 충전 !' 앱의 app_open_foreground 광고 단위 + private let adUnitID = "ca-app-pub-7469152743628808/3611967519" + #endif + + private var appOpenAd: AppOpenAd? + private var loadDate: Date? + private var isLoading = false + private var skipsNextAd = false + + /// 광고가 화면을 덮고 있는 동안 true + private(set) var isShowingAd = false + + private override init() { + super.init() + } +} + +extension AppOpenAdManager { + /// 마지막 광고 노출 시각. 앱을 다시 켜도 간격을 지키도록 저장한다. + private static var lastShownDate: Date? { + get { UserDefaults.standard.object(forKey: StringKey.LAST_APP_OPEN_AD_DATE) as? Date } + set { UserDefaults.standard.set(newValue, forKey: StringKey.LAST_APP_OPEN_AD_DATE) } + } + + private var isAdFresh: Bool { + guard let loadDate = loadDate else { return false } + return Date().timeIntervalSince(loadDate) < Self.adLifetime + } + + /// 다음 한 번의 복귀에는 광고를 띄우지 않는다. + /// 약관 링크, 광고 클릭처럼 앱이 사용자를 밖으로 보냈을 때 부른다. 돌아오자마자 광고가 뜨지 않게 한다. + @MainActor + func skipNextAd() { + skipsNextAd = true + } + + /// 다음 복귀 때 바로 띄울 수 있게 광고를 미리 불러 둔다. + @MainActor + func loadAdIfNeeded() async { + guard !PremiumAccess.isActive, !isLoading, !isShowingAd, + appOpenAd == nil || !isAdFresh, + AdConsentManager.shared.canRequestAds else { return } + + isLoading = true + defer { isLoading = false } + await AdConsentManager.shared.startMobileAdsIfNeeded() + do { + appOpenAd = try await AppOpenAd.load(with: adUnitID, request: Request()) + loadDate = Date() + } catch { + appOpenAd = nil + NSLog("앱 오프닝 광고 로드 실패: \(error.localizedDescription)") + } + } + + /// 백그라운드에서 돌아왔을 때 부른다. 조건이 맞을 때만 광고를 띄운다. + @MainActor + func showAdIfAvailable() { + guard !skipsNextAd else { + skipsNextAd = false + return + } + guard !PremiumAccess.isActive, + !isShowingAd, + !RewardedPassManager.shared.isShowingAd else { return } + if let lastShownDate = Self.lastShownDate, + Date().timeIntervalSince(lastShownDate) < Self.minimumInterval { + return + } + guard let ad = appOpenAd, isAdFresh else { + // 이번에는 건너뛰고 다음 복귀 때 쓸 광고를 불러 둔다. + Task { await loadAdIfNeeded() } + return + } + // 다른 팝업이 떠 있으면 그 위를 덮지 않는다. + guard let topViewController = UIApplication.topViewController(), + !(topViewController is UIAlertController) else { return } + + isShowingAd = true + ad.fullScreenContentDelegate = self + ad.present(from: topViewController) + } + + @MainActor + private func finishPresentation() { + isShowingAd = false + appOpenAd = nil + loadDate = nil + Task { await loadAdIfNeeded() } + ProPromotionManager.shared.presentIfNeeded() + } +} + +// MARK: - FullScreenContentDelegate +extension AppOpenAdManager: FullScreenContentDelegate { + @MainActor + func adWillPresentFullScreenContent(_ ad: FullScreenPresentingAd) { + Self.lastShownDate = Date() + } + + @MainActor + func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) { + finishPresentation() + } + + @MainActor + func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + NSLog("앱 오프닝 광고 표시 실패: \(error.localizedDescription)") + finishPresentation() + } +} diff --git a/Controllers/Controllers/Sources/Models/LiveActivityManager.swift b/Controllers/Controllers/Sources/Models/LiveActivityManager.swift index 9fd6d24..747e0d8 100644 --- a/Controllers/Controllers/Sources/Models/LiveActivityManager.swift +++ b/Controllers/Controllers/Sources/Models/LiveActivityManager.swift @@ -16,7 +16,7 @@ final class LiveActivityManager { // MARK: - Public API (버전 무관 진입점) - /// 컨트롤러 연결 시 Live Activity 를 시작한다. (프리미엄 구독자 전용) + /// 컨트롤러 연결 시 Live Activity 를 시작한다. (구독자·광고 이용권 사용자 전용) func start(vendorName: String, batteryLevel: Float, batteryState: Int) { if #available(iOS 16.1, *) { startActivity(vendorName: vendorName, batteryLevel: batteryLevel, batteryState: batteryState) @@ -47,8 +47,8 @@ final class LiveActivityManager { @available(iOS 16.1, *) private func startActivity(vendorName: String, batteryLevel: Float, batteryState: Int) { - // 구독자에게만 제공하는 기능 - guard StoreKitManager.shared.isSubscribed else { return } + // 구독자와 광고 이용권 사용자에게만 제공하는 기능 + guard PremiumAccess.isActive else { return } // 사용자가 Live Activity 를 허용한 경우에만 시작 guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } @@ -85,6 +85,11 @@ final class LiveActivityManager { @available(iOS 16.1, *) private func updateActivity(batteryLevel: Float, batteryState: Int) { guard let activity = currentActivity else { return } + // 광고 이용권이 끝나면 다음 갱신 때 닫는다. + guard PremiumAccess.isActive else { + endActivity() + return + } let contentState = ControllerActivityAttributes.ContentState( batteryLevel: batteryLevel, diff --git a/Controllers/Controllers/Sources/Models/ProPromotionManager.swift b/Controllers/Controllers/Sources/Models/ProPromotionManager.swift new file mode 100644 index 0000000..65846be --- /dev/null +++ b/Controllers/Controllers/Sources/Models/ProPromotionManager.swift @@ -0,0 +1,98 @@ +// +// ProPromotionManager.swift +// Controllers +// +// 유료 기능을 쓰지 않는 사용자가 앱을 켠 뒤 처음 컨트롤러를 연결하면 Pro를 소개하는 팝업을 한 번 띄운다. +// 팝업에서 바로 광고 이용권을 받거나 구매 화면으로 갈 수 있다. +// + +import UIKit + +@MainActor +final class ProPromotionManager { + + static let shared = ProPromotionManager() + + /// 앱 시작 때 알림 권한·광고 동의·ATT를 묻는 동안은 팝업을 미룬다. + var isLaunchFlowFinished = false { + didSet { presentIfNeeded() } + } + + /// 컨트롤러가 연결됐지만 아직 팝업을 띄우지 못했으면 true + private var isPending = false + /// 이번 실행에서 이미 처리했으면 true. 앱을 완전히 종료했다 켜면 다시 띄운다. + private var isDone = false + + private init() {} + + func controllerDidConnect() { + guard !isDone else { return } + isPending = true + presentIfNeeded() + } + + /// 띄울 수 있는 상태면 팝업을 띄운다. 지금 못 띄우면 앱 활성화·광고 닫힘 때 다시 시도한다. + func presentIfNeeded() { + guard isPending, isLaunchFlowFinished, + UIApplication.shared.applicationState == .active else { return } + guard !PremiumAccess.isActive else { + finish() + return + } + guard !AppOpenAdManager.shared.isShowingAd, + !RewardedPassManager.shared.isShowingAd, + let topViewController = UIApplication.topViewController(), + !(topViewController is UIAlertController) else { return } + + finish() + // 이미 구매 화면을 보고 있으면 같은 내용을 또 띄우지 않는다. + guard !(topViewController is IAPOnboardingViewController) else { return } + presentAlert(from: topViewController) + } + + private func finish() { + isPending = false + isDone = true + } + + private func presentAlert(from viewController: UIViewController) { + let threshold = "\(UserDefaults.shared.batteryNotificationThreshold)%" + let alert = UIAlertController(title: "Pro Promo Title".localized, + message: "Pro Promo Message %@".localized(with: [threshold]), + preferredStyle: .alert) + let watchAdAction = UIAlertAction(title: "Watch Ad Pro Pass %@".localized(with: [RewardedPassManager.passDurationText]), + style: .default) { [weak self] _ in + self?.showRewardedAd(from: viewController) + } + let plansAction = UIAlertAction(title: "See Pro Plans".localized, style: .default) { _ in + let onboardingVC = IAPOnboardingViewController() + onboardingVC.modalPresentationStyle = .overFullScreen + viewController.present(onboardingVC, animated: true) + } + alert.addAction(watchAdAction) + alert.addAction(plansAction) + alert.addAction(UIAlertAction(title: "Not Now".localized, style: .cancel)) + viewController.present(alert, animated: true) + } + + private func showRewardedAd(from viewController: UIViewController) { + // 광고를 불러오는 1~2초 동안 화면을 막고 진행 중임을 보여 준다. + let indicatorView = UIActivityIndicatorView(style: .large) + indicatorView.color = .white + indicatorView.backgroundColor = UIColor.black.withAlphaComponent(0.4) + if let window = viewController.view.window { + indicatorView.frame = window.bounds + indicatorView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(indicatorView) + } + indicatorView.startAnimating() + + Task { + let outcome = await RewardedPassManager.shared.showRewardedAd(from: viewController) + indicatorView.removeFromSuperview() + let alert = UIAlertController(title: nil, message: outcome.message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Ok!".localized, style: .default)) + viewController.present(alert, animated: true) + } + } +} diff --git a/Controllers/Controllers/Sources/Models/RewardedPassManager.swift b/Controllers/Controllers/Sources/Models/RewardedPassManager.swift new file mode 100644 index 0000000..f0d985e --- /dev/null +++ b/Controllers/Controllers/Sources/Models/RewardedPassManager.swift @@ -0,0 +1,164 @@ +// +// RewardedPassManager.swift +// Controllers +// +// 보상형 광고를 끝까지 보면 일정 시간 동안 프리미엄 기능(백그라운드 충전 알림, Live Activity, 광고 제거)을 열어 준다. +// 광고를 요청하기 전의 동의·ATT는 AdConsentManager가 맡는다. +// + +import UIKit +import GoogleMobileAds + +/// 유료 기능을 쓸 수 있는지. 구독 중이거나 광고 이용권이 남아 있으면 true +enum PremiumAccess { + static var isActive: Bool { + StoreKitManager.shared.isSubscribed || RewardedPassManager.isPassActive + } +} + +extension Notification.Name { + /// 광고 이용권을 받아 유료 기능이 새로 열렸을 때 + static let proPassGranted = Notification.Name("proPassGranted") +} + +final class RewardedPassManager: NSObject { + + enum Outcome { + /// 광고를 끝까지 봐서 이용권을 받음 + case rewarded + /// 보상 전에 광고를 닫음 + case dismissedEarly + /// 동의 거부, 광고 없음(no fill), 네트워크 오류 등으로 광고를 보여 주지 못함 + case unavailable + } + + static let shared = RewardedPassManager() + + /// 광고 1회 시청으로 주는 이용 시간 + static let passDuration: TimeInterval = 3 * 60 * 60 + + #if DEBUG + /// Google 테스트 보상형 광고 단위. 개발 중에는 실제 광고 단위를 쓰지 않는다. + private let adUnitID = "ca-app-pub-3940256099942544/1712485313" + #else + /// AdMob '아차 충전 !' 앱의 pro_pass_rewarded 광고 단위 + private let adUnitID = "ca-app-pub-7469152743628808/7469653173" + #endif + + private var presentedAd: RewardedAd? + private var didEarnReward = false + private var dismissContinuation: CheckedContinuation? + + /// 광고를 불러오거나 보여 주는 동안 true. 이때는 앱 오프닝 광고·Pro 팝업을 띄우지 않는다. + private(set) var isShowingAd = false + + private override init() { + super.init() + } +} + +// MARK: - Pass +extension RewardedPassManager { + /// 이용권 만료 시각. 이용권을 받은 적이 없으면 nil + static var passExpirationDate: Date? { + UserDefaults.standard.object(forKey: StringKey.PRO_PASS_EXPIRATION) as? Date + } + + static var isPassActive: Bool { + guard let expiration = passExpirationDate else { return false } + return expiration > Date() + } + + /// 예: "3시간", "3 hours", "3時間" + static var passDurationText: String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.allowedUnits = [.hour, .minute] + return formatter.string(from: passDuration) ?? "" + } + + private func grantPass() { + let expiration = Date().addingTimeInterval(Self.passDuration) + UserDefaults.standard.set(expiration, forKey: StringKey.PRO_PASS_EXPIRATION) + NotificationCenter.default.post(name: .proPassGranted, object: nil) + } +} + +// MARK: - Rewarded Ad +extension RewardedPassManager { + /// 동의·ATT를 거쳐 광고를 불러와 보여 준다. 끝까지 보면 이용권을 저장한다. + @MainActor + func showRewardedAd(from viewController: UIViewController) async -> Outcome { + isShowingAd = true + defer { isShowingAd = false } + guard await AdConsentManager.shared.prepare(from: viewController) else { return .unavailable } + + let ad: RewardedAd + do { + ad = try await RewardedAd.load(with: adUnitID, request: Request()) + } catch { + NSLog("보상형 광고 로드 실패: \(error.localizedDescription)") + return .unavailable + } + + let outcome = await present(ad, from: viewController) + if outcome == .rewarded { + grantPass() + } + return outcome + } + + @MainActor + private func present(_ ad: RewardedAd, from viewController: UIViewController) async -> Outcome { + await withCheckedContinuation { continuation in + dismissContinuation = continuation + didEarnReward = false + presentedAd = ad + ad.fullScreenContentDelegate = self + ad.present(from: viewController) { [weak self] in + self?.didEarnReward = true + } + } + } + + @MainActor + private func finishPresentation(_ outcome: Outcome) { + presentedAd = nil + dismissContinuation?.resume(returning: outcome) + dismissContinuation = nil + } +} + +// MARK: - FullScreenContentDelegate +extension RewardedPassManager: FullScreenContentDelegate { + @MainActor + func adDidRecordClick(_ ad: FullScreenPresentingAd) { + // 광고를 눌러 App Store 등에 갔다 돌아올 때 앱 오프닝 광고가 바로 뜨지 않게 한다. + AppOpenAdManager.shared.skipNextAd() + } + + @MainActor + func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) { + finishPresentation(didEarnReward ? .rewarded : .dismissedEarly) + } + + @MainActor + func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + NSLog("보상형 광고 표시 실패: \(error.localizedDescription)") + finishPresentation(.unavailable) + } +} + +extension RewardedPassManager.Outcome { + /// 결과를 사용자에게 알리는 문구 + var message: String { + switch self { + case .rewarded: + return "Pro Pass Granted %@".localized(with: [RewardedPassManager.passDurationText]) + case .dismissedEarly: + return "Ad Not Completed".localized + case .unavailable: + return "Ad Not Available".localized + } + } +} diff --git a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift index db7476a..8a651b9 100644 --- a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift +++ b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift @@ -74,6 +74,7 @@ final class IAPOnboardingViewController: UIViewController { label.translatesAutoresizingMaskIntoConstraints = false label.text = "Notifications can also be sent to the controller's battery information during game play".localized + + "\n\n" + "Pro No Ads".localized label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .center @@ -149,10 +150,42 @@ final class IAPOnboardingViewController: UIViewController { return button }() + /// 광고를 끝까지 보면 일정 시간 Pro 기능을 쓸 수 있다. + private lazy var rewardedPassButton: UIButton = { + var configuration = UIButton.Configuration.tinted() + configuration.image = UIImage(systemName: "play.rectangle.fill") + configuration.imagePadding = 8 + configuration.baseBackgroundColor = .systemPink + configuration.baseForegroundColor = .systemPink + configuration.cornerStyle = .medium + + let button = UIButton(configuration: configuration) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(tappedRewardedPassButton), for: .touchUpInside) + + return button + }() + + /// EU·영국 등 광고 동의가 필요한 지역에서만 보인다. + private lazy var adPrivacyButton: UIButton = { + let button = UIButton() + + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(tappedAdPrivacyButton), for: .touchUpInside) + button.setTitle("Ad Privacy Settings".localized, for: .normal) + button.setTitleColor(.secondaryLabel, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14) + button.setUnderline() + button.isHidden = true + + return button + }() + private lazy var premiumContentsView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(startPremiumButton) + view.addSubview(rewardedPassButton) view.backgroundColor = .systemBackground return view }() @@ -206,6 +239,7 @@ final class IAPOnboardingViewController: UIViewController { super.viewDidLoad() initLayout() + updateRewardedPassButton() Task { displayPrices = await storKitManager.displayPrices() } } @@ -233,7 +267,7 @@ extension IAPOnboardingViewController { [scrollView, premiumContentsView, priceLabel, closeButton, dimmedView].forEach { view.addSubview($0) } dimmedView.addSubview(indicatorView) scrollView.addSubview(scrollContentsView) - [titleLabel, infoLabel, monthlyButton, weeklyButton, yearlyButton, termOfUseButton, privacyPolicyButton].forEach { scrollContentsView.addSubview($0) } + [titleLabel, infoLabel, monthlyButton, weeklyButton, yearlyButton, termOfUseButton, privacyPolicyButton, adPrivacyButton].forEach { scrollContentsView.addSubview($0) } } private func addConstraints() { @@ -256,7 +290,8 @@ extension IAPOnboardingViewController { scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + // 아래 고정 영역(가격 + 구매 버튼 + 광고 버튼) 위에서 끝나야 약관 링크가 가려지지 않는다. + scrollView.bottomAnchor.constraint(equalTo: priceLabel.topAnchor, constant: -8), scrollContentsView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), scrollContentsView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), @@ -267,15 +302,20 @@ extension IAPOnboardingViewController { premiumContentsView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), premiumContentsView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), premiumContentsView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - premiumContentsView.heightAnchor.constraint(equalToConstant: 100), + premiumContentsView.heightAnchor.constraint(equalToConstant: 150), startPremiumButton.topAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.topAnchor, constant: 15), startPremiumButton.leadingAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.leadingAnchor, constant: 15), startPremiumButton.trailingAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.trailingAnchor, constant: -15), - startPremiumButton.bottomAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.bottomAnchor, constant: -15), + startPremiumButton.heightAnchor.constraint(equalToConstant: 70), + + rewardedPassButton.topAnchor.constraint(equalTo: startPremiumButton.bottomAnchor, constant: 10), + rewardedPassButton.leadingAnchor.constraint(equalTo: startPremiumButton.leadingAnchor), + rewardedPassButton.trailingAnchor.constraint(equalTo: startPremiumButton.trailingAnchor), + rewardedPassButton.bottomAnchor.constraint(equalTo: premiumContentsView.safeAreaLayoutGuide.bottomAnchor, constant: -10), ]) - let contentViewHeight = scrollContentsView.heightAnchor.constraint(greaterThanOrEqualTo: view.heightAnchor) + let contentViewHeight = scrollContentsView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) contentViewHeight.priority = .defaultLow contentViewHeight.isActive = true @@ -306,11 +346,14 @@ extension IAPOnboardingViewController { termOfUseButton.heightAnchor.constraint(equalTo: yearlyButton.heightAnchor, multiplier: 0.5), termOfUseButton.trailingAnchor.constraint(equalTo: yearlyButton.centerXAnchor, constant: -15), - termOfUseButton.bottomAnchor.constraint(equalTo: scrollContentsView.safeAreaLayoutGuide.bottomAnchor, constant: -(premiumContentsView.frame.height + 150)), + termOfUseButton.bottomAnchor.constraint(equalTo: adPrivacyButton.topAnchor, constant: -4), privacyPolicyButton.heightAnchor.constraint(equalTo: yearlyButton.heightAnchor, multiplier: 0.5), privacyPolicyButton.leadingAnchor.constraint(equalTo: yearlyButton.centerXAnchor, constant: 15), - privacyPolicyButton.bottomAnchor.constraint(equalTo: scrollContentsView.safeAreaLayoutGuide.bottomAnchor, constant: -(premiumContentsView.frame.height + 150)), + privacyPolicyButton.bottomAnchor.constraint(equalTo: adPrivacyButton.topAnchor, constant: -4), + + adPrivacyButton.bottomAnchor.constraint(equalTo: scrollContentsView.bottomAnchor, constant: -16), + adPrivacyButton.centerXAnchor.constraint(equalTo: scrollContentsView.centerXAnchor), ]) NSLayoutConstraint.activate([ @@ -343,6 +386,33 @@ extension IAPOnboardingViewController { indicatorView.alpha = 0 dimmedView.alpha = 0 } + + /// 이용권이 남아 있으면 만료 시각을 보여 주고 버튼을 막는다. + private func updateRewardedPassButton() { + if RewardedPassManager.isPassActive, let expiration = RewardedPassManager.passExpirationDate { + rewardedPassButton.configuration?.title = "Pro Pass Active %@".localized(with: [Self.expirationText(expiration)]) + rewardedPassButton.isEnabled = false + } else { + rewardedPassButton.configuration?.title = "Watch Ad Pro Pass %@".localized(with: [RewardedPassManager.passDurationText]) + rewardedPassButton.isEnabled = true + } + adPrivacyButton.isHidden = !AdConsentManager.shared.isPrivacyOptionsRequired + } + + private func showAlert(message: String, completion: (() -> Void)? = nil) { + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Ok!".localized, style: .default) { _ in completion?() }) + present(alert, animated: true) + } + + /// 예: "오늘 오후 9:30", "Today at 9:30 PM" + private static func expirationText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + formatter.doesRelativeDateFormatting = true + return formatter.string(from: date) + } } // MARK: - User Interactions @@ -365,6 +435,8 @@ extension IAPOnboardingViewController { @objc private func tappedTerms(_ button: UIButton) { + // Safari에서 돌아올 때 앱 오프닝 광고를 띄우지 않는다. + AppOpenAdManager.shared.skipNextAd() if button == termOfUseButton { let urlString = "https://voracious-pigment-aaf.notion.site/Terms-edb94eab4fd74b6ca85c3aac0b17b7d1?pvs=4" if let url = URL(string: urlString) { @@ -378,6 +450,29 @@ extension IAPOnboardingViewController { } } + @objc + private func tappedRewardedPassButton() { + startIndicator() + Task { @MainActor in + let outcome = await RewardedPassManager.shared.showRewardedAd(from: self) + stopIndicator() + updateRewardedPassButton() + showAlert(message: outcome.message) { [weak self] in + // 이용권을 받았으면 구매 화면을 닫는다. + if outcome == .rewarded { + self?.dismiss(animated: true) + } + } + } + } + + @objc + private func tappedAdPrivacyButton() { + Task { @MainActor in + await AdConsentManager.shared.presentPrivacyOptions(from: self) + } + } + @objc private func tappedStartPreminumButton() { print(#function, "subscriptionType: \(subscriptionType)") diff --git a/Controllers/Controllers/Sources/Setting/SettingViewController.swift b/Controllers/Controllers/Sources/Setting/SettingViewController.swift index 6a0bcbd..8027f0d 100644 --- a/Controllers/Controllers/Sources/Setting/SettingViewController.swift +++ b/Controllers/Controllers/Sources/Setting/SettingViewController.swift @@ -50,6 +50,10 @@ final class SettingViewController: UIViewController { fetchJSON() initLayout() configureTableView() + // 구매 화면(전체 화면 모달)에서 이용권을 받으면 프리미엄 행을 갱신한다. + NotificationCenter.default.addObserver(forName: .proPassGranted, object: nil, queue: .main) { [weak self] _ in + self?.tableView.reloadData() + } } } @@ -125,6 +129,13 @@ extension SettingViewController: UITableViewDataSource, UITableViewDelegate { if premiumType(of: item) == .chargeAlertThreshold { convertedCell.updateButtonTitle("\(UserDefaults.shared.batteryNotificationThreshold)%") } + if premiumType(of: item) == .premium, + !StoreKitManager.shared.isSubscribed, + RewardedPassManager.isPassActive, + let expiration = RewardedPassManager.passExpirationDate { + let time = DateFormatter.localizedString(from: expiration, dateStyle: .none, timeStyle: .short) + convertedCell.updateButtonTitle("~ \(time)") + } return convertedCell diff --git a/Controllers/Controllers/Supporting Files/Info.plist b/Controllers/Controllers/Supporting Files/Info.plist index b891d01..793adfc 100644 --- a/Controllers/Controllers/Supporting Files/Info.plist +++ b/Controllers/Controllers/Supporting Files/Info.plist @@ -6,6 +6,8 @@ com.controller.battery + GADApplicationIdentifier + ca-app-pub-7469152743628808~7969925639 GCSupportedGameControllers @@ -21,6 +23,211 @@ NSSupportsLiveActivities + NSUserTrackingUsageDescription + Used to show you more relevant ads. You get the same reward whether or not you allow this. + SKAdNetworkItems + + + SKAdNetworkIdentifier + cstr6suwn9.skadnetwork + + + SKAdNetworkIdentifier + 4fzdc2evr5.skadnetwork + + + SKAdNetworkIdentifier + 2fnua5tdw4.skadnetwork + + + SKAdNetworkIdentifier + ydx93a7ass.skadnetwork + + + SKAdNetworkIdentifier + p78axxw29g.skadnetwork + + + SKAdNetworkIdentifier + v72qych5uu.skadnetwork + + + SKAdNetworkIdentifier + ludvb6z3bs.skadnetwork + + + SKAdNetworkIdentifier + cp8zw746q7.skadnetwork + + + SKAdNetworkIdentifier + 3sh42y64q3.skadnetwork + + + SKAdNetworkIdentifier + c6k4g5qg8m.skadnetwork + + + SKAdNetworkIdentifier + s39g8k73mm.skadnetwork + + + SKAdNetworkIdentifier + wg4vff78zm.skadnetwork + + + SKAdNetworkIdentifier + 3qy4746246.skadnetwork + + + SKAdNetworkIdentifier + f38h382jlk.skadnetwork + + + SKAdNetworkIdentifier + hs6bdukanm.skadnetwork + + + SKAdNetworkIdentifier + mlmmfzh3r3.skadnetwork + + + SKAdNetworkIdentifier + v4nxqhlyqp.skadnetwork + + + SKAdNetworkIdentifier + wzmmz9fp6w.skadnetwork + + + SKAdNetworkIdentifier + su67r6k2v3.skadnetwork + + + SKAdNetworkIdentifier + yclnxrl5pm.skadnetwork + + + SKAdNetworkIdentifier + t38b2kh725.skadnetwork + + + SKAdNetworkIdentifier + 7ug5zh24hu.skadnetwork + + + SKAdNetworkIdentifier + gta9lk7p23.skadnetwork + + + SKAdNetworkIdentifier + vutu7akeur.skadnetwork + + + SKAdNetworkIdentifier + y5ghdn5j9k.skadnetwork + + + SKAdNetworkIdentifier + v9wttpbfk9.skadnetwork + + + SKAdNetworkIdentifier + n38lu8286q.skadnetwork + + + SKAdNetworkIdentifier + 47vhws6wlr.skadnetwork + + + SKAdNetworkIdentifier + kbd757ywx3.skadnetwork + + + SKAdNetworkIdentifier + 9t245vhmpl.skadnetwork + + + SKAdNetworkIdentifier + a2p9lx4jpn.skadnetwork + + + SKAdNetworkIdentifier + 22mmun2rn5.skadnetwork + + + SKAdNetworkIdentifier + 44jx6755aq.skadnetwork + + + SKAdNetworkIdentifier + k674qkevps.skadnetwork + + + SKAdNetworkIdentifier + 4468km3ulz.skadnetwork + + + SKAdNetworkIdentifier + 2u9pt9hc89.skadnetwork + + + SKAdNetworkIdentifier + 8s468mfl3y.skadnetwork + + + SKAdNetworkIdentifier + klf5c3l5u5.skadnetwork + + + SKAdNetworkIdentifier + ppxm28t8ap.skadnetwork + + + SKAdNetworkIdentifier + kbmxgpxpgc.skadnetwork + + + SKAdNetworkIdentifier + uw77j35x4d.skadnetwork + + + SKAdNetworkIdentifier + 578prtvx9j.skadnetwork + + + SKAdNetworkIdentifier + 4dzt52r2t5.skadnetwork + + + SKAdNetworkIdentifier + tl55sbb4fm.skadnetwork + + + SKAdNetworkIdentifier + c3frkrj4fj.skadnetwork + + + SKAdNetworkIdentifier + e5fvkxwrpn.skadnetwork + + + SKAdNetworkIdentifier + 8c4e2ghe7u.skadnetwork + + + SKAdNetworkIdentifier + 3rd42ekr43.skadnetwork + + + SKAdNetworkIdentifier + 97r2b46745.skadnetwork + + + SKAdNetworkIdentifier + 3qcr597p9d.skadnetwork + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/Controllers/Controllers/Supporting Files/en.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/en.lproj/InfoPlist.strings index b7ce898..8b8d470 100644 --- a/Controllers/Controllers/Supporting Files/en.lproj/InfoPlist.strings +++ b/Controllers/Controllers/Supporting Files/en.lproj/InfoPlist.strings @@ -9,3 +9,6 @@ /* 현지화된 번들 표시 이름 */ "CFBundleDisplayName" = "Ah Charge!"; + +/* ATT tracking permission prompt */ +"NSUserTrackingUsageDescription" = "Used to show you more relevant ads. The app and ad rewards work the same whether or not you allow this."; diff --git a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings index 8061c39..9c7e3cf 100644 --- a/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/en.lproj/Localizable.strings @@ -35,3 +35,18 @@ "/month" = "/month"; "/year" = "/year"; "No subscription to restore" = "No subscription to restore"; + +// MARK: - Rewarded Pro Pass +"Watch Ad Pro Pass %@" = "Watch an ad for %@ of Pro"; +"Pro Pass Active %@" = "Pro active until %@"; +"Pro Pass Granted %@" = "You can use Pro for %@."; +"Ad Not Completed" = "Watch the whole ad to get the pass."; +"Ad Not Available" = "No ad is available right now. Please try again later."; +"Ad Privacy Settings" = "Ad Privacy Settings"; + +// MARK: - Pro Promotion +"Pro Promo Title" = "Get battery alerts while you play"; +"Pro Promo Message %@" = "With Pro, you'll be notified when your controller battery drops to %@ or below, even while using other apps. No ads, either."; +"See Pro Plans" = "See Pro Plans"; +"Not Now" = "Not Now"; +"Pro No Ads" = "Use the app without ads."; diff --git a/Controllers/Controllers/Supporting Files/ja.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/ja.lproj/InfoPlist.strings index a7e3a22..f7606dc 100644 --- a/Controllers/Controllers/Supporting Files/ja.lproj/InfoPlist.strings +++ b/Controllers/Controllers/Supporting Files/ja.lproj/InfoPlist.strings @@ -8,3 +8,6 @@ /* 현지화된 번들 표시 이름 */ "CFBundleDisplayName" = "忘れないで!"; + +/* ATT トラッキング許可の説明 */ +"NSUserTrackingUsageDescription" = "より関連性の高い広告を表示するために使用します。許可しなくても、アプリの機能や広告の特典は変わりません。"; diff --git a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings index 5e240b5..811b4ed 100644 --- a/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ja.lproj/Localizable.strings @@ -36,3 +36,18 @@ "/month" = "/月"; "/year" = "/年"; "No subscription to restore" = "復元できるサブスクリプションがありません。"; + +// MARK: - Rewarded Pro Pass +"Watch Ad Pro Pass %@" = "広告を見て%@無料でProを使う"; +"Pro Pass Active %@" = "%@までPro利用中"; +"Pro Pass Granted %@" = "%@の間、Pro機能を使えます。"; +"Ad Not Completed" = "広告を最後まで見るとパスがもらえます。"; +"Ad Not Available" = "現在、広告を読み込めません。しばらくしてからもう一度お試しください。"; +"Ad Privacy Settings" = "広告のプライバシー設定"; + +// MARK: - Pro Promotion +"Pro Promo Title" = "ゲーム中もバッテリーをお知らせ"; +"Pro Promo Message %@" = "Proなら、他のアプリを使っている間もコントローラーのバッテリーが%@以下になるとお知らせします。広告も表示されません。"; +"See Pro Plans" = "Proプランを見る"; +"Not Now" = "あとで"; +"Pro No Ads" = "広告なしでご利用いただけます。"; diff --git a/Controllers/Controllers/Supporting Files/ko.lproj/InfoPlist.strings b/Controllers/Controllers/Supporting Files/ko.lproj/InfoPlist.strings index af157fd..adeb4da 100644 --- a/Controllers/Controllers/Supporting Files/ko.lproj/InfoPlist.strings +++ b/Controllers/Controllers/Supporting Files/ko.lproj/InfoPlist.strings @@ -8,3 +8,6 @@ /* 현지화된 번들 표시 이름 */ "CFBundleDisplayName" = "아차 충전 !"; + +/* ATT 추적 허용 요청 문구 */ +"NSUserTrackingUsageDescription" = "맞춤 광고를 보여 드리는 데 사용됩니다. 허용하지 않아도 앱 기능과 광고 보상은 똑같아요."; diff --git a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings index 16a2d49..36c5cb9 100644 --- a/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings +++ b/Controllers/Controllers/Supporting Files/ko.lproj/Localizable.strings @@ -43,3 +43,18 @@ "/month" = "/월"; "/year" = "/연"; "No subscription to restore" = "복원할 구독이 없습니다."; + +// MARK: - Rewarded Pro Pass +"Watch Ad Pro Pass %@" = "광고 보고 %@ 무료로 Pro 쓰기"; +"Pro Pass Active %@" = "%@까지 Pro 이용 중"; +"Pro Pass Granted %@" = "%@ 동안 Pro 기능을 쓸 수 있어요."; +"Ad Not Completed" = "광고를 끝까지 봐야 이용권을 받을 수 있어요."; +"Ad Not Available" = "지금은 광고를 불러올 수 없어요. 잠시 후 다시 시도해 주세요."; +"Ad Privacy Settings" = "광고 개인정보 설정"; + +// MARK: - Pro Promotion +"Pro Promo Title" = "게임하는 동안 배터리 알림 받기"; +"Pro Promo Message %@" = "Pro를 쓰면 다른 앱을 쓰는 중에도 컨트롤러 배터리가 %@ 이하로 떨어질 때 알려 드려요. 광고도 나오지 않아요."; +"See Pro Plans" = "Pro 구독 보기"; +"Not Now" = "나중에"; +"Pro No Ads" = "광고 없이 이용할 수 있습니다."; From d8f11e8c4e19744ad6414189437d315ae617a286 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 13 Oct 2026 21:04:45 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=95=BD=EA=B4=80=C2=B7=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=EC=A0=95=EB=B3=B4=20=EC=B2=98=EB=A6=AC=EB=B0=A9?= =?UTF-8?q?=EC=B9=A8=20=EB=A7=81=ED=81=AC=EB=A5=BC=20dy-solution.kr?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Sources/Setting/IAPOnboardingViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift index 8a651b9..98031ed 100644 --- a/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift +++ b/Controllers/Controllers/Sources/Setting/IAPOnboardingViewController.swift @@ -438,12 +438,12 @@ extension IAPOnboardingViewController { // Safari에서 돌아올 때 앱 오프닝 광고를 띄우지 않는다. AppOpenAdManager.shared.skipNextAd() if button == termOfUseButton { - let urlString = "https://voracious-pigment-aaf.notion.site/Terms-edb94eab4fd74b6ca85c3aac0b17b7d1?pvs=4" + let urlString = "https://dy-solution.kr/terms.html" if let url = URL(string: urlString) { UIApplication.shared.open(url) } } else if button == privacyPolicyButton { - let urlString = "https://voracious-pigment-aaf.notion.site/30f23504cad7456e9472ac959c3e95b8?pvs=4" + let urlString = "https://dy-solution.kr/privacy.html#acha-charge" if let url = URL(string: urlString) { UIApplication.shared.open(url) } From fb51a9ec039b13c454535a25917becb26c4f77f4 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 13 Oct 2026 21:04:45 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=201.1.1=20=EB=B2=84=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Controllers/Controllers.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Controllers/Controllers.xcodeproj/project.pbxproj b/Controllers/Controllers.xcodeproj/project.pbxproj index 9bbf8ae..8af76aa 100644 --- a/Controllers/Controllers.xcodeproj/project.pbxproj +++ b/Controllers/Controllers.xcodeproj/project.pbxproj @@ -889,7 +889,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -922,7 +922,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1037,7 +1037,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1071,7 +1071,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.arex.achaCharge.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 96e575e905f53cefbc8a8aa33798aa14bda26e90 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 13 Oct 2026 21:04:45 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=EA=B4=91=EA=B3=A0=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0(=EB=B3=B4=EC=83=81=ED=98=95=C2=B7=EC=95=B1=20?= =?UTF-8?q?=EC=98=A4=ED=94=84=EB=8B=9D=C2=B7Pro=20=ED=8C=9D=EC=97=85)?= =?UTF-8?q?=EC=99=80=20=EA=B2=B0=EC=A0=95=20#12=C2=B7#13=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 29 +++++++++++++++++++------ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9cca601..62c1c53 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,6 +17,8 @@ | 컨트롤러 페어링·입력 | `GameController` 프레임워크 (ControllerKit으로 래핑) | [GameControllerManager.swift](Controllers/ControllerKit/Sources/Controller/GameControllerManager.swift) | | 백그라운드 알림 | `BGAppRefreshTask` + 로컬 알림 + 사용자 설정 기준값 | [FetchGameControllerOperation.swift](Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift) | | 잠금화면 노출 | ActivityKit Live Activity (iOS 16.1+) | [LiveActivityManager.swift](Controllers/Controllers/Sources/Models/LiveActivityManager.swift) | +| 광고 이용권 | AdMob 보상형 광고 → 기기 안에서 시간제 Pro | [RewardedPassManager.swift](Controllers/Controllers/Sources/Models/RewardedPassManager.swift) | +| 비구독자 광고·Pro 유도 | 앱 오프닝 광고(복귀 시) + 컨트롤러 연결 시 Pro 팝업 | [AppOpenAdManager.swift](Controllers/Controllers/Sources/Models/AppOpenAdManager.swift), [ProPromotionManager.swift](Controllers/Controllers/Sources/Models/ProPromotionManager.swift) | --- @@ -269,6 +271,60 @@ GCController 알림 (.GCControllerDidConnect / DidDisconnect) --- +## 결정 #12 — 광고 이용권: AdMob 보상형 광고 + 기기 안 만료 시각 + +**결정**: 구독하지 않은 사용자도 보상형 광고를 끝까지 보면 `RewardedPassManager.passDuration` 동안 유료 기능(백그라운드 충전 알림, Live Activity)을 쓴다. 유료 기능 판정은 `PremiumAccess.isActive`(구독 중 ‖ 이용권 남음) 한 곳으로 모은다. + +| 항목 | 구현 | +|---|---| +| 광고 SDK | Google Mobile Ads 13.x + UMP 3.x (SPM) | +| 광고 요청 순서 | UMP 동의 갱신·필요 시 동의 화면 → ATT 요청 → `MobileAds.start` → `RewardedAd.load` → 표시. 동의·ATT는 `AdConsentManager`가 맡고 앱 시작 때도 묻는다(결정 #13) | +| 이용권 저장 | 만료 시각을 `UserDefaults.standard`(`StringKey.PRO_PASS_EXPIRATION`)에 저장. 시청 중에는 다시 받을 수 없음(누적 없음) | +| 유료 기능 판정 | BGTask 실행·예약, Live Activity 시작·갱신, 앱 활성화 시 Live Activity 정리 모두 `PremiumAccess.isActive` | +| 광고 단위 | DEBUG는 Google 테스트 단위, Release는 AdMob 'pro_pass_rewarded'. 앱 ID는 Info.plist `GADApplicationIdentifier` | +| EU 동의 | AdMob '아차 충전 iOS - 유럽 규정' 메시지. 동의가 필요한 지역이면 구매 화면에 '광고 개인정보 설정' 링크 표시 | + +**근거**: +- 결제하지 않는 사용자에게도 유료 기능을 맛보게 하고 광고 수익을 얻음. 가이드라인 3.2.2(x)는 광고 시청에 대한 보상을 허용 +- 판정을 기기 안에서 하는 이유는 결정 #7의 서버 검증 미사용과 같음 (유료 기능이 기기 안에서만 동작) +- ATT 허용 여부와 관계없이 보상은 같음 → 허용은 맞춤 광고 단가에만 영향 + +**트레이드오프**: +- 광고 SDK가 기기 식별자·광고 데이터·대략적 위치·진단 정보를 수집 → App Store 개인정보 라벨(추적 포함)과 개인정보처리방침(dy-solution.kr)을 함께 갱신해야 함 +- 기기 시계를 바꾸면 이용권을 늘릴 수 있음 (서버 시간 검증 없음) +- 이용권이 끝나도 실행 중인 Live Activity는 다음 갱신이나 앱 활성화 때 닫힘 +- AdMob 앱 인증은 App Store의 개발자 웹사이트(마케팅 URL) 도메인에 있는 app-ads.txt로 확인됨 → 마케팅 URL이 비어 있으면 광고 게재가 제한됨 + +**재검토 기준**: 구독 전환율이 떨어지면 `passDuration`을 줄이거나 하루 시청 횟수를 제한. 광고 수익이 의미 없으면 SDK째 제거(개인정보 라벨·방침도 되돌림). + +--- + +## 결정 #13 — 비구독자 광고: 앱 오프닝 광고 + 컨트롤러 연결 시 Pro 팝업 + +**결정**: 유료 기능을 쓰지 않는 사용자(`!PremiumAccess.isActive`)에게 두 가지를 보여 준다. 구독자·광고 이용권 사용자에게는 보이지 않으며, 이로써 "광고 제거"가 Pro 혜택이 된다. + +| 항목 | 구현 | +|---|---| +| 앱 시작 흐름 | `SceneDelegate.runLaunchFlow` (실행당 1회): 알림 권한 답(최대 10초 대기) → 비구독자면 UMP 동의·ATT·SDK 시작 → 앱 오프닝 광고 미리 로드 → Pro 팝업 허용 | +| 앱 오프닝 광고 | 백그라운드에서 돌아올 때만(`sceneDidBecomeActive`에서 직전에 `sceneDidEnterBackground`를 거쳤는지로 판단). 마지막 노출 후 15분(`AppOpenAdManager.minimumInterval`, `UserDefaults.standard`에 저장), 불러온 지 4시간 지난 광고는 버림 | +| 앱 오프닝 광고를 건너뛰는 경우 | 약관 링크·광고 클릭으로 앱이 사용자를 밖으로 보낸 직후 1회(`skipNextAd()`), 다른 광고·팝업(`UIAlertController`)이 떠 있을 때 | +| Pro 팝업 | 앱 실행 후 첫 컨트롤러 연결 때 1회(날짜와 무관, 메모리 플래그). 앱 시작 흐름이 끝나고 다른 광고·팝업이 없을 때 표시. 구매 화면을 보고 있으면 생략 | +| 광고 단위 | 앱 오프닝 'app_open_foreground'. DEBUG는 Google 테스트 단위 | + +**근거**: +- AdMob 정책상 앱 로드·복귀 시점에 전면(interstitial) 광고는 금지. 이 시점용 형식이 앱 오프닝 광고 +- 컨트롤러를 연결한 순간이 백그라운드 충전 알림이 가장 와닿는 때. 기존에는 Pro 진입점이 설정 탭 한 곳뿐이었음 +- 알림 권한 팝업이 떠 있으면 앱이 비활성이라 ATT 팝업이 무시됨 → 순서를 코드로 강제 + +**트레이드오프**: +- 배터리만 잠깐 확인하고 나가는 사용자에게 광고가 끼어듦 → 간격 15분으로 완화 +- Pro 팝업은 컨트롤러를 켠 채 앱을 실행할 때마다 뜸(연결 이벤트가 실행 직후 옴) +- 알림 권한 팝업이 10초 안에 뜨지 않으면 다음 단계로 넘어감. 그 뒤에 뜨더라도 `AdConsentManager.prepare()`가 앱이 다시 활성화될 때까지 기다리므로 ATT와 겹치지 않음 + +**재검토 기준**: 리뷰·별점에서 광고 불만이 늘면 간격을 늘리거나 앱 오프닝 광고를 끔. 팝업이 성가시다는 의견이 많으면 하루 1회로 제한. + +--- + ## 알려진 부채 / 향후 결정 후보 다음 항목은 의사결정이 보류되었거나 미해결 상태입니다. 작업이 시작될 때 신규 "결정 #N"으로 승격하세요. diff --git a/CLAUDE.md b/CLAUDE.md index 9e67b10..3f67045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ **AchaCharge** ("아차! 충전 까먹었다")는 Game Controller의 배터리 충전 시기를 알려주는 iOS 앱입니다. 내부 Xcode 프로젝트명은 `Controllers`이며, 홈 화면 표시 이름은 ko `아차 충전 !` / en `Ah Charge!` / ja `忘れないで!` 입니다. -- **핵심 기능**: 컨트롤러 배터리 모니터링 · 버튼 입력 테스터 · 백그라운드 저전력 알림 · 홈 화면 위젯 · Live Activity · 월 구독(IAP) +- **핵심 기능**: 컨트롤러 배터리 모니터링 · 버튼 입력 테스터 · 백그라운드 저전력 알림 · 홈 화면 위젯 · Live Activity · 월 구독(IAP) · 광고 시청 Pro 이용권 - **지원 OS**: 앱/위젯 iOS 15.0 이상 (1.1.0부터 — Xcode 27이 지원하는 최소값). 테스트 타깃 16.4, Live Activity 16.1+ 런타임 가드. macOS 타깃(14.1)은 템플릿만 존재 -- **외부 의존성**: [SwiftyStoreKit](https://github.com/bizz84/SwiftyStoreKit) 0.16.4 (SPM) 1개 + 로컬 패키지 `ControllerKit` +- **외부 의존성**: [SwiftyStoreKit](https://github.com/bizz84/SwiftyStoreKit) 0.16.4, Google Mobile Ads 13.x, Google UMP 3.x (모두 SPM) + 로컬 패키지 `ControllerKit` - **공식 지원 컨트롤러**: Sony PlayStation DualSense Wireless Controller 상세 슬로건·외부 소개는 [README.md](README.md) 참조. @@ -109,6 +109,10 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers - `GameControllerManager.shared` (ControllerKit) — 연결 감지·배터리 조회·입력 매핑 - `StoreKitManager.shared` — 구독 여부, 상품 ID 로드 - `LiveActivityManager.shared` — Live Activity 시작/갱신/종료 + - `RewardedPassManager.shared` — 보상형 광고(로드·표시)와 광고 이용권 + - `AdConsentManager.shared` — 광고 요청 전 UMP 동의·ATT·SDK 시작 + - `AppOpenAdManager.shared` — 포어그라운드 복귀 시 앱 오프닝 광고 + - `ProPromotionManager.shared` — 컨트롤러 연결 시 Pro 소개 팝업 - 새 Manager 추가 시 같은 패턴 유지 ### ControllerKit (로컬 패키지) @@ -117,7 +121,7 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers ### 영속화 - **`UserDefaults.shared`** (App Group `group.arex.achaCharge`) — 컨트롤러 연결 여부, 배터리, 알림 기준값 등 위젯과 공유하는 값 -- **`UserDefaults.standard`** — 구독 여부(`StringKey.IS_SUBSCRIBED`)만 저장. 위젯에서 읽을 수 없음 +- **`UserDefaults.standard`** — 구독 여부(`StringKey.IS_SUBSCRIBED`), 광고 이용권 만료 시각(`StringKey.PRO_PASS_EXPIRATION`), 마지막 앱 오프닝 광고 시각(`StringKey.LAST_APP_OPEN_AD_DATE`). 위젯에서 읽을 수 없음 - 헬퍼: [UserDefaults+.swift](Controllers/Controllers/Sources/Extensions/UserDefaults+.swift) (`batteryNotificationThreshold` 등 계산 프로퍼티) - 키는 `StringKey` 네임스페이스에 상수로 정의 — 새 키 추가 시 동일 패턴 - CoreData/Realm/Keychain 미사용. 도입 시 ARCHITECTURE.md에 의사결정 기록 필요 @@ -128,11 +132,11 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers - 위젯이 읽는 키: `StringKey.CONTROLLER_CONNECTED`, `StringKey.BATTERY_LEVEL` - 위젯에서 Push를 보내던 코드는 제거됨 (커밋 `ba362d5`). 위젯 → 앱 통신을 다시 넣지 마세요 -### 백그라운드 알림 (구독자 전용) -- `AppDelegate`가 BGTask를 등록 → `SceneDelegate.sceneDidEnterBackground`에서 구독자일 때만 `scheduleAppRefresh()` +### 백그라운드 알림 (구독자·광고 이용권 사용자 전용) +- `AppDelegate`가 BGTask를 등록 → `SceneDelegate.sceneDidEnterBackground`에서 `PremiumAccess.isActive`일 때만 `scheduleAppRefresh()` - 작업 실행 시 [FetchGameControllerOperation](Controllers/Controllers/Sources/Models/FetchGameControllerOperation.swift)이 배터리를 읽어 **기준값(기본 20%) 이하일 때만** 로컬 알림 발송 -### Live Activity (iOS 16.1+, 구독자 전용) +### Live Activity (iOS 16.1+, 구독자·광고 이용권 사용자 전용) - `ControllerActivityAttributes`는 **앱 + 위젯 양쪽 타깃 멤버십** 필요 - ActivityKit 코드는 전부 `@available(iOS 16.1, *)` + 런타임 `if #available` 가드. 앱 배포 타깃(15.0)은 올리지 않음 - 갱신은 로컬(ActivityKit)만 사용, Push 기반 갱신 없음 @@ -146,6 +150,19 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers - 서버 검증은 쓰지 않습니다. 유료 기능이 기기 안에서만 동작해서, 서버 검증이 보안상 추가로 막는 게 없습니다 - `hotfix/1.0.12-iapverify`는 develop에 머지됐지만 그 검증 코드는 남아 있지 않습니다. 위 StoreKit 2 판정이 그 역할을 대신합니다 - 상품 ID는 절대 코드에 하드코딩 금지 — `ProductIDs.plist`에서 로드 +- 유료 기능 여부는 `StoreKitManager.shared.isSubscribed`가 아니라 **`PremiumAccess.isActive`**(구독 ‖ 광고 이용권)로 판단 + +### 광고 (AdMob) +유료 기능을 쓰지 않는 사용자(`!PremiumAccess.isActive`)에게만 광고가 나갑니다. 구독자와 광고 이용권 사용자는 광고를 보지 않습니다. + +- **앱 시작 흐름** (`SceneDelegate.runLaunchFlow`, 실행당 1회): 알림 권한 답 기다림(최대 10초) → 구독자가 아니면 [AdConsentManager](Controllers/Controllers/Sources/Models/AdConsentManager.swift)가 UMP 동의 → ATT → SDK 시작 → 앱 오프닝 광고 미리 로드 → Pro 팝업 허용. 시스템 팝업이 겹치지 않게 이 순서를 지키세요 +- **보상형 → 광고 이용권** ([RewardedPassManager.swift](Controllers/Controllers/Sources/Models/RewardedPassManager.swift)): 구매 화면 버튼·Pro 팝업에서 시청. 끝까지 보면 `passDuration` 동안 이용권 +- **앱 오프닝 광고** ([AppOpenAdManager.swift](Controllers/Controllers/Sources/Models/AppOpenAdManager.swift)): 백그라운드에서 돌아올 때만(콜드 스타트·제어 센터 제외), 마지막 노출 후 `minimumInterval`(15분)이 지나야 표시. AdMob 정책상 앱을 열거나 돌아오는 시점에는 전면(interstitial) 광고를 쓰면 안 됩니다 +- **Pro 팝업** ([ProPromotionManager.swift](Controllers/Controllers/Sources/Models/ProPromotionManager.swift)): 앱 실행 후 첫 컨트롤러 연결 때 1회. 광고 이용권 받기 / 구매 화면 / 나중에 +- 앱이 사용자를 밖으로 보내는 코드(`UIApplication.shared.open` 등)를 추가하면 그 직전에 `AppOpenAdManager.shared.skipNextAd()`를 부르세요. 안 그러면 돌아오자마자 광고가 뜹니다 +- DEBUG 빌드는 Google 테스트 광고 단위를 씀. 실제 광고 단위로 개발 중 광고를 누르지 마세요(AdMob 무효 트래픽) +- AdMob 앱 ID `ca-app-pub-7469152743628808~7969925639`(Info.plist `GADApplicationIdentifier`), 보상형 `pro_pass_rewarded` `.../7469653173`, 앱 오프닝 `app_open_foreground` `.../3611967519` +- 광고 SDK가 데이터를 수집하므로 App Store 개인정보 라벨(추적 포함)과 dy-solution.kr 개인정보처리방침을 같이 관리. 근거는 ARCHITECTURE.md 결정 #12, #13 ### 로컬라이제이션 - ko / en / ja `Localizable.strings` + `InfoPlist.strings` From 9081abc6bbc0e60185119e824e07b1a610167744 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 13 Oct 2026 21:07:27 +0900 Subject: [PATCH 5/5] =?UTF-8?q?ci:=20Google=20Mobile=20Ads=2013=EC=9D=B4?= =?UTF-8?q?=20Xcode=2016=20=EC=9D=B4=EC=83=81=EC=9D=84=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=ED=95=B4=20macos-15=20=EB=9F=AC=EB=84=88=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ios.yml | 5 +++-- CLAUDE.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 7c4b864..2f67f75 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -13,13 +13,14 @@ concurrency: jobs: build-test: name: Build & Test (iOS Simulator) - runs-on: macos-14 + # Google Mobile Ads 13.x 헤더가 Xcode 16 이상에서만 컴파일된다(NS_SWIFT_SENDING). + runs-on: macos-15 timeout-minutes: 30 env: PROJECT_PATH: Controllers/Controllers.xcodeproj SCHEME: Controllers - DESTINATION: platform=iOS Simulator,name=iPhone 15 + DESTINATION: generic/platform=iOS Simulator steps: - name: Checkout diff --git a/CLAUDE.md b/CLAUDE.md index 3f67045..d0bef38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ xcodebuild build -project Controllers/Controllers.xcodeproj -scheme Controllers ### CI -[.github/workflows/ios.yml](.github/workflows/ios.yml) — `main`/`develop` push·PR 시 `macos-14`에서 `Controllers` scheme **빌드만** 수행. test 단계는 위 TestAction 문제로 비활성화. +[.github/workflows/ios.yml](.github/workflows/ios.yml) — `main`/`develop` push·PR 시 `macos-15`(Xcode 16 이상 — Google Mobile Ads 13 요구)에서 `Controllers` scheme **빌드만** 수행. test 단계는 위 TestAction 문제로 비활성화. ### 주요 식별자