diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e0f6d2a..b8faaed 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,6 +9,8 @@ reviews: profile: assertive auto_review: enabled: true + base_branches: + - "develop" drafts: false auto_incremental_review: false tools: diff --git a/.github/workflows/coderabbit-ready-for-review.yml b/.github/workflows/coderabbit-ready-for-review.yml new file mode 100644 index 0000000..466fed1 --- /dev/null +++ b/.github/workflows/coderabbit-ready-for-review.yml @@ -0,0 +1,19 @@ +name: Request CodeRabbit review + +on: + pull_request: + branches: [develop] + types: [ready_for_review] + +permissions: + issues: write + +jobs: + request-review: + runs-on: ubuntu-latest + steps: + - name: Request full review + run: gh pr comment "$PR_URL" --body '@coderabbitai full review' + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ github.event.pull_request.html_url }} diff --git a/Tekken8 Frame Data/TK8.xcodeproj/project.pbxproj b/Tekken8 Frame Data/TK8.xcodeproj/project.pbxproj index bfb1dd6..5ab6a6d 100644 --- a/Tekken8 Frame Data/TK8.xcodeproj/project.pbxproj +++ b/Tekken8 Frame Data/TK8.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AD8700000000000000000001 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = AD8700000000000000000002 /* FirebaseRemoteConfig */; }; E48DD6EE2D78738A000308CC /* PostgREST in Frameworks */ = {isa = PBXBuildFile; productRef = E48DD6ED2D78738A000308CC /* PostgREST */; }; E48DD6F02D78738A000308CC /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = E48DD6EF2D78738A000308CC /* Storage */; }; E48DD6F32D787391000308CC /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = E48DD6F22D787391000308CC /* Supabase */; }; @@ -94,6 +95,7 @@ E4E2D9FC2E670CBB003DCB4F /* FirebaseAnalyticsIdentitySupport in Frameworks */, E48DD6EE2D78738A000308CC /* PostgREST in Frameworks */, E4E2D9F82E670CBB003DCB4F /* FirebaseAnalytics in Frameworks */, + AD8700000000000000000001 /* FirebaseRemoteConfig in Frameworks */, E48DD6F32D787391000308CC /* Supabase in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -160,6 +162,7 @@ E48DD6F22D787391000308CC /* Supabase */, E4D6BF502E6582480091C030 /* GoogleMobileAds */, E4E2D9F72E670CBB003DCB4F /* FirebaseAnalytics */, + AD8700000000000000000002 /* FirebaseRemoteConfig */, E4E2D9F92E670CBB003DCB4F /* FirebaseAnalyticsCore */, E4E2D9FB2E670CBB003DCB4F /* FirebaseAnalyticsIdentitySupport */, ); @@ -415,6 +418,9 @@ E4024B412D3F399F007C855C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ADMOB_APP_ID = "ca-app-pub-3866042653915701~9914634913"; + ADMOB_BANNER_AD_UNIT_ID = "ca-app-pub-3866042653915701/3419960087"; + ADMOB_NATIVE_AD_UNIT_ID = "ca-app-pub-3866042653915701/4686004711"; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -480,6 +486,9 @@ E4024B422D3F399F007C855C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ADMOB_APP_ID = "ca-app-pub-3866042653915701~9914634913"; + ADMOB_BANNER_AD_UNIT_ID = "ca-app-pub-3866042653915701/3419960087"; + ADMOB_NATIVE_AD_UNIT_ID = "ca-app-pub-3866042653915701/4686004711"; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -690,6 +699,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + AD8700000000000000000002 /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = E4E2D9F62E670CBB003DCB4F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; E48DD6ED2D78738A000308CC /* PostgREST */ = { isa = XCSwiftPackageProductDependency; package = E48DD6EC2D78738A000308CC /* XCRemoteSwiftPackageReference "supabase-swift" */; diff --git a/Tekken8 Frame Data/TK8/App/AppDelegate.swift b/Tekken8 Frame Data/TK8/App/AppDelegate.swift index d04186c..21e1bf6 100644 --- a/Tekken8 Frame Data/TK8/App/AppDelegate.swift +++ b/Tekken8 Frame Data/TK8/App/AppDelegate.swift @@ -19,12 +19,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let isDebugBuild = false #endif - if TK8AnalyticsCollectionPolicy.shouldCollect( + let collectAnalytics = TK8AnalyticsCollectionPolicy.shouldCollect( isDebugBuild: isDebugBuild, launchArguments: ProcessInfo.processInfo.arguments - ) { + ) + if collectAnalytics { FirebaseApp.configure() - Analytics.setAnalyticsCollectionEnabled(true) + Analytics.setAnalyticsCollectionEnabled(collectAnalytics) } } return true diff --git a/Tekken8 Frame Data/TK8/Character/Controller/CharacterListViewController.swift b/Tekken8 Frame Data/TK8/Character/Controller/CharacterListViewController.swift index 4eed71a..3679d21 100644 --- a/Tekken8 Frame Data/TK8/Character/Controller/CharacterListViewController.swift +++ b/Tekken8 Frame Data/TK8/Character/Controller/CharacterListViewController.swift @@ -22,6 +22,7 @@ final class CharacterListViewController: BaseViewController { private let searchAnalyticsTracker: SearchAnalyticsTracker private var hasLoadedCharacters = false private var shouldLogMemoEntryImpression = true + private var pendingImageKeys = Set() private let preference: CharacterLayoutPreference private var currentLayoutMode: CharacterCollectionViewMode @@ -88,6 +89,7 @@ final class CharacterListViewController: BaseViewController { private func recordVisibleScreen() { guard presentedViewController == nil else { return } + bannerAdHost?.appear() analytics.log(.screenViewed(.characterList)) if shouldLogMemoEntryImpression { analytics.log(.memoEntryImpression()) @@ -136,7 +138,7 @@ final class CharacterListViewController: BaseViewController { } @objc private func settingsButtonTapped() { - let settingsViewController = SettingViewController(analytics: analytics) + let settingsViewController = container.makeSettingViewController() navigationController?.pushViewController(settingsViewController, animated: true) } @@ -192,6 +194,12 @@ final class CharacterListViewController: BaseViewController { self?.updateSnapshot(for: filteredCharacters) } .store(in: &subscriptionSet) + + characterListViewModel.characterImagesPublisher + .sink { [weak self] images in + self?.reconfigureCharacters(with: images) + } + .store(in: &subscriptionSet) } private func fetchCharacters() { @@ -262,16 +270,14 @@ private extension CharacterListViewController { cell.contentConfiguration = UIHostingConfiguration { CharacterCell( character: itemIdentifier, - characterImagePublisher: self.characterListViewModel.characterImagesPublisher, - characterImages: self.characterListViewModel.characterImages + image: self.characterListViewModel.image(for: itemIdentifier.nameEN) ) } case .grid: cell.contentConfiguration = UIHostingConfiguration { CharacterGridCell( character: itemIdentifier, - characterImagePublisher: self.characterListViewModel.characterImagesPublisher, - characterImages: self.characterListViewModel.characterImages + image: self.characterListViewModel.image(for: itemIdentifier.nameEN) ) } } @@ -291,9 +297,21 @@ private extension CharacterListViewController { let attemptID = searchAnalyticsTracker.attemptID dataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in - self?.searchAnalyticsTracker.resultsApplied(count: characters.count, for: attemptID) + guard let self else { return } + self.searchAnalyticsTracker.resultsApplied(count: characters.count, for: attemptID) + self.reconfigureCharacters(with: self.characterListViewModel.characterImages) } } + + func reconfigureCharacters(with images: [String: UIImage]) { + pendingImageKeys.formUnion(images.keys) + guard !pendingImageKeys.isEmpty, var snapshot = dataSource?.snapshot() else { return } + let items = snapshot.itemIdentifiers.filter { pendingImageKeys.contains($0.nameEN) } + guard !items.isEmpty else { return } + pendingImageKeys.subtract(items.map(\.nameEN)) + snapshot.reconfigureItems(items) + dataSource?.apply(snapshot, animatingDifferences: false) + } } // MARK: - UICollectionViewDelegate Conformance diff --git a/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterCell.swift b/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterCell.swift index 8f6b623..b37e039 100644 --- a/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterCell.swift +++ b/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterCell.swift @@ -5,7 +5,6 @@ // Created by 문영균 on 3/28/25. // -import Combine import Foundation import SwiftUI @@ -19,8 +18,7 @@ struct CharacterCell: View, ReuseIdentifiable { return (character.nameEN, nil) } } - let characterImagePublisher: AnyPublisher<[String : UIImage], Never> - @State var characterImages: [String: UIImage] + let image: UIImage? var body: some View { HStack(alignment: .center, spacing: 12) { @@ -46,9 +44,6 @@ struct CharacterCell: View, ReuseIdentifiable { .foregroundStyle(.white.opacity(0.3)) .padding(.trailing, 4) } - .onReceive(characterImagePublisher) { images in - characterImages = images - } .padding(6) .background( RoundedRectangle(cornerRadius: 14) @@ -62,9 +57,7 @@ struct CharacterCell: View, ReuseIdentifiable { @ViewBuilder private var characterImage: some View { - let img = characterImages[character.nameEN] - - Image(uiImage: img ?? UIImage(named: "mokujin")!) + Image(uiImage: image ?? UIImage(named: "mokujin")!) .resizable() .scaledToFill() .frame( @@ -89,7 +82,6 @@ private enum Constants { #Preview { CharacterCell( character: Character(id: 1, nameEN: "Nina Williams", nameKR: "니나 윌리엄스", imageURL: "https://i.ibb.co/GXN7B5k/nina.png"), - characterImagePublisher: Empty().eraseToAnyPublisher(), - characterImages: [:], + image: nil ) } diff --git a/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterGridCell.swift b/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterGridCell.swift index cdf8696..5514c01 100644 --- a/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterGridCell.swift +++ b/Tekken8 Frame Data/TK8/Character/View/Cell/CharacterGridCell.swift @@ -3,14 +3,12 @@ // TK8 // -import Combine import Foundation import SwiftUI struct CharacterGridCell: View, ReuseIdentifiable { var character: Character - let characterImagePublisher: AnyPublisher<[String: UIImage], Never> - @State var characterImages: [String: UIImage] + let image: UIImage? private var localizedName: String { let preferredLanguage = Bundle.main.preferredLocalizations.first @@ -50,15 +48,10 @@ struct CharacterGridCell: View, ReuseIdentifiable { .stroke(.white.opacity(0.12), lineWidth: 0.5) ) } - .onReceive(characterImagePublisher) { images in - characterImages = images - } } private var characterImage: some View { - let img = characterImages[character.nameEN] - - return Image(uiImage: img ?? UIImage(named: "mokujin")!) + Image(uiImage: image ?? UIImage(named: "mokujin")!) .resizable() .scaledToFill() } @@ -81,8 +74,7 @@ private enum Constants { nameKR: "니나 윌리엄스", imageURL: "https://i.ibb.co/GXN7B5k/nina.png" ), - characterImagePublisher: Empty().eraseToAnyPublisher(), - characterImages: [:] + image: nil ) .frame(width: 120, height: 160) .background(Color(uiColor: .tkBackground) diff --git a/Tekken8 Frame Data/TK8/CharacterSelect/Controller/CharacterSelectViewController.swift b/Tekken8 Frame Data/TK8/CharacterSelect/Controller/CharacterSelectViewController.swift index bc130df..52ef81c 100644 --- a/Tekken8 Frame Data/TK8/CharacterSelect/Controller/CharacterSelectViewController.swift +++ b/Tekken8 Frame Data/TK8/CharacterSelect/Controller/CharacterSelectViewController.swift @@ -24,6 +24,7 @@ final class CharacterSelectViewController: BaseViewController { private let characterSelectView: CharacterCollectionView private let searchController: UISearchController private var dataSource: CharacterDataSource? + private var pendingImageKeys = Set() weak var delegate: Selectable? private let currentLayoutMode: CharacterCollectionViewMode @@ -63,6 +64,13 @@ final class CharacterSelectViewController: BaseViewController { self?.updateSnapshot(for: characters) } .store(in: &subscriptionSet) + + viewModel.characterImagesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] images in + self?.reconfigureCharacters(with: images) + } + .store(in: &subscriptionSet) } override func setupDataSource() { @@ -126,16 +134,14 @@ private extension CharacterSelectViewController { cell.contentConfiguration = UIHostingConfiguration{ CharacterCell( character: itemIdentifier, - characterImagePublisher: self.viewModel.characterImagesPublisher, - characterImages: self.viewModel.characterImages + image: self.viewModel.image(for: itemIdentifier.nameEN) ) } case .grid: cell.contentConfiguration = UIHostingConfiguration{ CharacterGridCell( character: itemIdentifier, - characterImagePublisher: self.viewModel.characterImagesPublisher, - characterImages: self.viewModel.characterImages + image: self.viewModel.image(for: itemIdentifier.nameEN) ) } } @@ -148,6 +154,19 @@ private extension CharacterSelectViewController { var snapshot = Snapshot() snapshot.appendSections([.main]) snapshot.appendItems(characters, toSection: .main) + dataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in + guard let self else { return } + self.reconfigureCharacters(with: self.viewModel.characterImages) + } + } + + func reconfigureCharacters(with images: [String: UIImage]) { + pendingImageKeys.formUnion(images.keys) + guard !pendingImageKeys.isEmpty, var snapshot = dataSource?.snapshot() else { return } + let items = snapshot.itemIdentifiers.filter { pendingImageKeys.contains($0.nameEN) } + guard !items.isEmpty else { return } + pendingImageKeys.subtract(items.map(\.nameEN)) + snapshot.reconfigureItems(items) dataSource?.apply(snapshot, animatingDifferences: false) } } diff --git a/Tekken8 Frame Data/TK8/Info.plist b/Tekken8 Frame Data/TK8/Info.plist index 8a35d1e..d3312db 100644 --- a/Tekken8 Frame Data/TK8/Info.plist +++ b/Tekken8 Frame Data/TK8/Info.plist @@ -2,6 +2,21 @@ + GADApplicationIdentifier + $(ADMOB_APP_ID) + AdMobBannerAdUnitID + $(ADMOB_BANNER_AD_UNIT_ID) + AdMobNativeAdUnitID + $(ADMOB_NATIVE_AD_UNIT_ID) + GADDelayAppMeasurementInit + + SKAdNetworkItems + + + SKAdNetworkIdentifier + cstr6suwn9.skadnetwork + + API_KEY $(API_KEY) CFBundleDocumentTypes diff --git a/Tekken8 Frame Data/TK8/Localizable.xcstrings b/Tekken8 Frame Data/TK8/Localizable.xcstrings index 3a3cc70..d0b1081 100644 --- a/Tekken8 Frame Data/TK8/Localizable.xcstrings +++ b/Tekken8 Frame Data/TK8/Localizable.xcstrings @@ -1,6 +1,47 @@ { "sourceLanguage": "en", "strings": { + "Advertisement": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Advertisement" } }, + "ko": { "stringUnit": { "state": "translated", "value": "광고" } } + } + }, + "Ad privacy options": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ad privacy options" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "광고 개인정보 설정" + } + } + } + }, + "Unable to load privacy options. Please try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to load privacy options. Please try again." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개인정보 설정을 불러올 수 없습니다. 다시 시도해 주세요." + } + } + } + }, "-9~-1": { "extractionState": "manual", "localizations": { diff --git a/Tekken8 Frame Data/TK8/Move/Controller/MoveListViewController.swift b/Tekken8 Frame Data/TK8/Move/Controller/MoveListViewController.swift index 11e24b7..39968dd 100644 --- a/Tekken8 Frame Data/TK8/Move/Controller/MoveListViewController.swift +++ b/Tekken8 Frame Data/TK8/Move/Controller/MoveListViewController.swift @@ -10,8 +10,8 @@ import SwiftUI import UIKit final class MoveListViewController: BaseViewController { - private typealias Snapshot = NSDiffableDataSourceSnapshot - private typealias MoveDataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + private typealias MoveDataSource = UICollectionViewDiffableDataSource private let moveListView: MoveListView private let moveListViewModel: MoveListViewModel @@ -22,6 +22,7 @@ final class MoveListViewController: BaseViewController { private var fetchStateCancellable: AnyCancellable? private let analytics: AnalyticsClient private let searchAnalyticsTracker: SearchAnalyticsTracker + private let nativeAdLoader: NativeMoveAdLoader? private var hasLoadedInitialData = false private var isScreenVisible = false private var appliedMoveCount = 0 @@ -32,7 +33,8 @@ final class MoveListViewController: BaseViewController { character: Character, moveListViewModel viewModel: MoveListViewModel, container: DIContainer, - analytics: AnalyticsClient + analytics: AnalyticsClient, + nativeAdService: (any BannerAdServing)? = nil ) { moveListView = MoveListView() moveListViewModel = viewModel @@ -40,6 +42,11 @@ final class MoveListViewController: BaseViewController { searchController = UISearchController(searchResultsController: nil) self.character = character self.analytics = analytics + if let nativeAdService, nativeAdService.configuration.usesNativeMoveAds { + nativeAdLoader = NativeMoveAdLoader(service: nativeAdService, analytics: analytics) + } else { + nativeAdLoader = nil + } searchAnalyticsTracker = SearchAnalyticsTracker( scope: .moveList, analytics: analytics, @@ -65,12 +72,14 @@ final class MoveListViewController: BaseViewController { analytics.log(.screenViewed(.moveList)) isScreenVisible = true logInitialDisplayIfNeeded() + nativeAdLoader?.load(placements: Self.nativeAdPlacements(moveCount: moveListViewModel.filtered.count), from: self) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) isScreenVisible = false searchAnalyticsTracker.cancel() + nativeAdLoader?.reset() } override func setupDelegation() { @@ -99,7 +108,7 @@ final class MoveListViewController: BaseViewController { } @objc private func settingsButtonTapped() { - let settingsViewController = SettingViewController(analytics: analytics) + let settingsViewController = container.makeSettingViewController() navigationController?.pushViewController(settingsViewController, animated: true) } @@ -124,6 +133,11 @@ final class MoveListViewController: BaseViewController { self?.applySnapshot(for: filteredMoves) } + nativeAdLoader?.onAdsChanged = { [weak self] in + guard let self else { return } + self.applySnapshot(for: self.moveListViewModel.filtered) + } + fetchStateCancellable = moveListViewModel .$fetchState .receive(on: DispatchQueue.main) @@ -192,16 +206,25 @@ private extension MoveListViewController { func setupDiffableDataSource() { // Cell 등록 dataSource = MoveDataSource(collectionView: moveListView.moveCollectionView) { collectionView, indexPath, itemIdentifier in - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MoveCell.reuseIdentifier, for: indexPath) - cell.contentConfiguration = UIHostingConfiguration { - MoveCell(move: itemIdentifier) + switch itemIdentifier { + case .move(let move): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MoveCell.reuseIdentifier, for: indexPath) + cell.contentConfiguration = UIHostingConfiguration { + MoveCell(move: move) + } + return cell + case .nativeAd(let placement): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NativeMoveAdCardCell.reuseIdentifier, for: indexPath) as! NativeMoveAdCardCell + if let ad = self.nativeAdLoader?.nativeAd(for: placement) { + cell.configure(with: ad) + } + return cell } - return cell } // 헤더용 SupplementaryRegisteration 정의 let headerRegisteration = UICollectionView.SupplementaryRegistration( elementKind: UICollectionView.elementKindSectionHeader) { headerView, elementKind, indexPath in - let sectionTitle = self.dataSource?.snapshot().sectionIdentifiers[indexPath.section] ?? "" + guard case .moves(let sectionTitle)? = self.dataSource?.snapshot().sectionIdentifiers[indexPath.section] else { return } headerView.titleLabel.text = sectionTitle } // CollectionView에 SupplimentaryRegistration 등록 @@ -210,6 +233,7 @@ private extension MoveListViewController { forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: MoveSectionHeaderView.reuseIdentifier ) + moveListView.moveCollectionView.register(NativeMoveAdCardCell.self, forCellWithReuseIdentifier: NativeMoveAdCardCell.reuseIdentifier) // DiffableDataSource에 provider로 연결 dataSource?.supplementaryViewProvider = { collectionView, kind, indexPath in collectionView.dequeueConfiguredReusableSupplementary( @@ -221,16 +245,53 @@ private extension MoveListViewController { func applySnapshot(for moves: [LocalizedMove]) { var snapshot = Snapshot() - let orderSections = orderedSections(from: moves) - snapshot.appendSections(orderSections) - + let availableNativeAdPlacements = Set(Self.nativeAdPlacements(moveCount: moves.count).filter { + nativeAdLoader?.nativeAd(for: $0) != nil + }) + var displayedMoveCount = 0 + for section in orderSections { let items = moves .filter { $0.section == section } .sorted { $0.id < $1.id } - snapshot.appendItems(items, toSection: section) + .map(MoveListItem.move) + + let sectionEndCount = displayedMoveCount + items.count + let placements = availableNativeAdPlacements.filter { + displayedMoveCount < $0 && $0 <= sectionEndCount + }.sorted() + if placements.isEmpty { + snapshot.appendSections([.moves(section)]) + snapshot.appendItems(items, toSection: .moves(section)) + } else { + snapshot.appendSections([.moves(section)]) + var itemOffset = 0 + var destinationSection = MoveListSection.moves(section) + for placement in placements { + let itemCount = placement - displayedMoveCount - itemOffset + snapshot.appendItems(Array(items[itemOffset..<(itemOffset + itemCount)]), toSection: destinationSection) + snapshot.appendSections([.nativeAd(placement)]) + snapshot.appendItems([.nativeAd(placement)], toSection: .nativeAd(placement)) + itemOffset += itemCount + if itemOffset < items.count { + destinationSection = .continuation(section, placement) + snapshot.appendSections([destinationSection]) + } + } + if itemOffset < items.count { + snapshot.appendItems(Array(items.dropFirst(itemOffset)), toSection: destinationSection) + } + } + displayedMoveCount += items.count } + let headerlessIndexes = Set(snapshot.sectionIdentifiers.enumerated().compactMap { index, section in + switch section { + case .nativeAd, .continuation: index + case .moves: nil + } + }) + moveListView.setHeaderlessSectionIndexes(headerlessIndexes) let attemptID = searchAnalyticsTracker.attemptID dataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in guard let self else { return } @@ -238,6 +299,9 @@ private extension MoveListViewController { self.searchAnalyticsTracker.resultsApplied(count: moves.count, for: attemptID) self.logInitialDisplayIfNeeded() } + if isScreenVisible { + nativeAdLoader?.load(placements: Self.nativeAdPlacements(moveCount: moves.count), from: self) + } } func logInitialDisplayIfNeeded() { @@ -287,6 +351,13 @@ private extension MoveListViewController { } } +extension MoveListViewController { + static func nativeAdPlacements(moveCount: Int) -> [Int] { + guard moveCount >= 4 else { return [] } + return Array(stride(from: 4, through: moveCount, by: 20)) + } +} + // MARK: - UICollectionViewDelegate Conformance // TODO: 추후 특정 기술에 대한 액션을 추가한다면 필요할지도? extension MoveListViewController: UICollectionViewDelegate { @@ -299,4 +370,15 @@ private extension MoveListViewController { enum Texts { static let placeholder = "Search" } + + enum MoveListSection: Hashable { + case moves(String) + case nativeAd(Int) + case continuation(String, Int) + } + + enum MoveListItem: Hashable { + case move(LocalizedMove) + case nativeAd(Int) + } } diff --git a/Tekken8 Frame Data/TK8/Move/View/Cell/NativeMoveAdCardCell.swift b/Tekken8 Frame Data/TK8/Move/View/Cell/NativeMoveAdCardCell.swift new file mode 100644 index 0000000..dfd5f26 --- /dev/null +++ b/Tekken8 Frame Data/TK8/Move/View/Cell/NativeMoveAdCardCell.swift @@ -0,0 +1,95 @@ +import GoogleMobileAds +import UIKit + +final class NativeMoveAdCardCell: UICollectionViewCell { + static let reuseIdentifier = "NativeMoveAdCardCell" + + private let adView = NativeAdView() + private let sponsoredLabel = UILabel() + private let headlineLabel = UILabel() + private let bodyLabel = UILabel() + private let mediaView = MediaView() + private let callToActionButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + adView.nativeAd = nil + headlineLabel.text = nil + bodyLabel.text = nil + mediaView.mediaContent = nil + callToActionButton.setTitle(nil, for: .normal) + callToActionButton.isHidden = true + } + + func configure(with ad: NativeAd) { + headlineLabel.text = ad.headline + bodyLabel.text = ad.body + bodyLabel.isHidden = ad.body == nil + mediaView.mediaContent = ad.mediaContent + callToActionButton.setTitle(ad.callToAction, for: .normal) + callToActionButton.isHidden = ad.callToAction == nil + + adView.headlineView = headlineLabel + adView.bodyView = bodyLabel + adView.mediaView = mediaView + adView.callToActionView = callToActionButton + // The SDK owns click handling after this assignment; this cell adds no tap + // recognizers or custom click behavior. + adView.nativeAd = ad + } + + private func setupViews() { + contentView.backgroundColor = .clear + adView.backgroundColor = .secondarySystemBackground + adView.layer.cornerRadius = 12 + adView.clipsToBounds = true + adView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(adView) + + sponsoredLabel.text = NSLocalizedString("Advertisement", comment: "Native ad attribution") + sponsoredLabel.font = .preferredFont(forTextStyle: .caption2) + sponsoredLabel.textColor = .secondaryLabel + headlineLabel.font = .preferredFont(forTextStyle: .headline) + headlineLabel.numberOfLines = 2 + bodyLabel.font = .preferredFont(forTextStyle: .subheadline) + bodyLabel.textColor = .secondaryLabel + bodyLabel.numberOfLines = 2 + mediaView.backgroundColor = .tertiarySystemFill + mediaView.layer.cornerRadius = 8 + mediaView.clipsToBounds = true + mediaView.translatesAutoresizingMaskIntoConstraints = false + callToActionButton.titleLabel?.font = .preferredFont(forTextStyle: .subheadline) + callToActionButton.tintColor = .white + callToActionButton.backgroundColor = .tkRed + callToActionButton.layer.cornerRadius = 8 + callToActionButton.isUserInteractionEnabled = false + + let stack = UIStackView(arrangedSubviews: [sponsoredLabel, headlineLabel, bodyLabel, mediaView, callToActionButton]) + stack.axis = .vertical + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + adView.addSubview(stack) + + NSLayoutConstraint.activate([ + adView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + adView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + adView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + adView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: adView.topAnchor, constant: 12), + stack.leadingAnchor.constraint(equalTo: adView.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: adView.trailingAnchor, constant: -12), + stack.bottomAnchor.constraint(equalTo: adView.bottomAnchor, constant: -12), + mediaView.heightAnchor.constraint(equalToConstant: 150), + callToActionButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } +} diff --git a/Tekken8 Frame Data/TK8/Move/View/MoveListView.swift b/Tekken8 Frame Data/TK8/Move/View/MoveListView.swift index cb0d5e7..7350484 100644 --- a/Tekken8 Frame Data/TK8/Move/View/MoveListView.swift +++ b/Tekken8 Frame Data/TK8/Move/View/MoveListView.swift @@ -8,6 +8,7 @@ import UIKit final class MoveListView: BaseView { + private var headerlessSectionIndexes = Set() // MARK: Subviews @@ -18,19 +19,7 @@ final class MoveListView: BaseView { let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - - let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) - let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: headerSize, - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .top - ) - section.boundarySupplementaryItems = [sectionHeader] - let layout = UICollectionViewCompositionalLayout(section: section) - - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: MoveCell.reuseIdentifier) return collectionView @@ -61,9 +50,37 @@ final class MoveListView: BaseView { func setCollectionViewDelegate(_ delegate: UICollectionViewDelegate) { moveCollectionView.delegate = delegate } + + func setHeaderlessSectionIndexes(_ indexes: Set) { + headerlessSectionIndexes = indexes + moveCollectionView.setCollectionViewLayout(makeLayout(), animated: false) + } } private extension MoveListView { + func makeLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] index, _ in + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + + if self?.headerlessSectionIndexes.contains(index) == true { + section.contentInsets = .zero + } else { + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [sectionHeader] + } + return section + } + } + func setupCollectionViewLayouts() { moveCollectionView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Tekken8 Frame Data/TK8/Settings/Controller/SettingViewController.swift b/Tekken8 Frame Data/TK8/Settings/Controller/SettingViewController.swift index 849a993..d3d7dec 100644 --- a/Tekken8 Frame Data/TK8/Settings/Controller/SettingViewController.swift +++ b/Tekken8 Frame Data/TK8/Settings/Controller/SettingViewController.swift @@ -3,11 +3,12 @@ import UIKit class SettingViewController: BaseViewController, MFMailComposeViewControllerDelegate { private let tableView: SettingTableView - private let settingsItems: [SettingItem] = [ - .appVersion, - .tekkenVersion, - .reportIssue - ] + private var settingsItems: [SettingItem] { + var items: [SettingItem] = [.appVersion, .tekkenVersion, .reportIssue] + if ads?.privacyOptionsRequired == true { items.append(.adPrivacy) } + return items + } + private let ads: BannerAdService? private let analytics: AnalyticsClient @@ -16,8 +17,9 @@ class SettingViewController: BaseViewController, MFMailComposeViewControllerDele analytics.log(.screenViewed(.settings)) } - init(analytics: AnalyticsClient) { + init(analytics: AnalyticsClient, ads: BannerAdService? = nil) { self.analytics = analytics + self.ads = ads tableView = SettingTableView() super.init(nibName: nil, bundle: nil) @@ -40,6 +42,13 @@ class SettingViewController: BaseViewController, MFMailComposeViewControllerDele setupTableViewDelegation() } + override func bindViewModel() { + ads?.$privacyOptionsRequired.removeDuplicates().sink { [weak self] _ in + // Published values are delivered before assignment. + DispatchQueue.main.async { self?.tableView.tableView.reloadData() } + }.store(in: &subscriptionSet) + } + private func setupTableViewDelegation() { tableView.setTableViewDelegate(self) tableView.setTableViewDataSource(self) @@ -124,6 +133,18 @@ extension SettingViewController: UITableViewDelegate { switch item { case .reportIssue: sendReportMail() + case .adPrivacy: + Task { + do { + try await ads?.presentPrivacyOptions(from: self) + } catch { + let alert = UIAlertController(title: "Ad privacy options".localized(), + message: "Unable to load privacy options. Please try again.".localized(), + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Accept".localized(), style: .default)) + present(alert, animated: true) + } + } default: break } @@ -135,6 +156,7 @@ private extension SettingViewController { case appVersion case tekkenVersion case reportIssue + case adPrivacy var title: String { switch self { @@ -144,6 +166,8 @@ private extension SettingViewController { "Tekken version".localized() case .reportIssue: "Report".localized() + case .adPrivacy: + "Ad privacy options".localized() } } @@ -160,7 +184,7 @@ private extension SettingViewController { var showsDisclosureIndicator: Bool { switch self { - case .reportIssue: + case .reportIssue, .adPrivacy: true default: false diff --git a/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdHost.swift b/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdHost.swift new file mode 100644 index 0000000..2e499b5 --- /dev/null +++ b/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdHost.swift @@ -0,0 +1,189 @@ +import Combine +import GoogleMobileAds +import UIKit + +/// Owns an ad only while its screen is eligible. Never changes the screen's data source. +@MainActor +final class BannerAdHost: NSObject, BannerViewDelegate { + private weak var controller: UIViewController? + private let service: any BannerAdServing + private let loadAd: (BannerView) -> Void + private let isApplicationActive: () -> Bool + private let placement: BannerPlacement + private let analytics: AnalyticsClient + private let container = UIView() + private var height: NSLayoutConstraint! + private var fullContentBottom: NSLayoutConstraint! + private var adContentBottom: NSLayoutConstraint! + private var subscriptions = Set() + private var policy = BannerAdPolicy() + private var banner: BannerView? + private var requestedWidth: CGFloat = 0 + private var failedThisVisit = false + private var preparing = false + private var impressionRecorded = false + + init(service: any BannerAdServing, placement: BannerPlacement, analytics: AnalyticsClient, + loadAd: @escaping (BannerView) -> Void = { $0.load(Request()) }, + isApplicationActive: @escaping () -> Bool = { UIApplication.shared.applicationState == .active }) { + self.service = service + self.placement = placement + self.analytics = analytics + self.loadAd = loadAd + self.isApplicationActive = isApplicationActive + } + + func install(in controller: UIViewController) { + self.controller = controller + let content = controller.view! + let wrapper = UIView() + wrapper.backgroundColor = content.backgroundColor ?? .tkBackground + controller.view = wrapper + wrapper.addSubview(content) + wrapper.addSubview(container) + content.translatesAutoresizingMaskIntoConstraints = false + container.translatesAutoresizingMaskIntoConstraints = false + container.clipsToBounds = true + container.isHidden = true + container.accessibilityIdentifier = "admob_banner_" + placement.rawValue + height = container.heightAnchor.constraint(equalToConstant: 0) + fullContentBottom = content.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor) + adContentBottom = content.bottomAnchor.constraint(equalTo: container.topAnchor) + NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: wrapper.topAnchor), + content.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor), + content.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor), + fullContentBottom, + container.leadingAnchor.constraint(equalTo: wrapper.safeAreaLayoutGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: wrapper.safeAreaLayoutGuide.trailingAnchor), + container.bottomAnchor.constraint(equalTo: wrapper.safeAreaLayoutGuide.bottomAnchor), height + ]) + service.statePublisher + .sink { [weak self] enabled, consent in + guard let self else { return } + if enabled != self.policy.enabled || consent != self.policy.consentAllowsAds { + self.failedThisVisit = false + } + self.policy.enabled = enabled + self.policy.consentAllowsAds = consent + self.update() + }.store(in: &subscriptions) + NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) + .sink { [weak self] notification in + guard let self, let window = self.controller?.viewIfLoaded?.window, + let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let keyboard = window.convert(frame, from: nil) + self.policy.keyboardVisible = window.bounds.intersection(keyboard).height > 0 + self.update() + }.store(in: &subscriptions) + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .sink { [weak self] _ in + self?.policy.keyboardVisible = false + self?.update() + }.store(in: &subscriptions) + NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification) + .sink { [weak self] _ in self?.removeBanner() }.store(in: &subscriptions) + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in self?.update() }.store(in: &subscriptions) + } + + func appear() { + policy.visible = true + failedThisVisit = false + update() + } + + func disappear() { + policy.visible = false + removeBanner() + } + + func setEditing(_ editing: Bool) { + policy.editing = editing + update() + } + + func layout() { + if let controller, requestedWidth != 0, + abs(controller.view.safeAreaLayoutGuide.layoutFrame.width - requestedWidth) > 1 { + removeBanner() + failedThisVisit = false + } + update() + } + + private func update() { + guard let controller else { return } + guard policy.enabled, policy.visible, !policy.keyboardVisible, !policy.editing, + isApplicationActive() else { + removeBanner() + return + } + guard controller.presentedViewController == nil else { return } + if !policy.consentAllowsAds { + removeBanner() + guard !preparing, !failedThisVisit else { return } + preparing = true + Task { [weak self] in + guard let self else { return } + let allowed = await self.service.prepare(from: controller) + self.preparing = false + if !allowed { self.failedThisVisit = true } + self.update() + } + return + } + guard policy.canLoad, banner == nil, !failedThisVisit, + let adUnitID = service.configuration.adUnitID else { return } + let width = controller.view.safeAreaLayoutGuide.layoutFrame.width + guard width >= 320 else { return } + requestedWidth = width + let banner = BannerView(adSize: currentOrientationAnchoredAdaptiveBanner(width: width)) + banner.adUnitID = adUnitID + banner.rootViewController = controller + banner.delegate = self + banner.translatesAutoresizingMaskIntoConstraints = false + self.banner = banner + container.addSubview(banner) + NSLayoutConstraint.activate([ + banner.centerXAnchor.constraint(equalTo: container.centerXAnchor), + banner.topAnchor.constraint(equalTo: container.topAnchor) + ]) + // Keep the container collapsed until an ad actually loads. + loadAd(banner) + } + + private func removeBanner() { + banner?.delegate = nil + banner?.removeFromSuperview() + banner = nil + impressionRecorded = false + requestedWidth = 0 + container.isHidden = true + height?.constant = 0 + adContentBottom?.isActive = false + fullContentBottom?.isActive = true + } + + func bannerViewDidReceiveAd(_ bannerView: BannerView) { + guard bannerView === banner, policy.canLoad else { return } + impressionRecorded = false + fullContentBottom.isActive = false + adContentBottom.isActive = true + height.constant = bannerView.adSize.size.height + container.isHidden = false + } + + func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) { + guard bannerView === banner else { return } + analytics.log(.bannerLoadFailed(placement: placement, code: (error as NSError).code)) + failedThisVisit = true + removeBanner() + } + + func bannerViewDidRecordImpression(_ bannerView: BannerView) { + guard bannerView === banner, policy.canLoad, !container.isHidden, !impressionRecorded else { return } + impressionRecorded = true + analytics.log(.bannerImpression(placement: placement)) + } +} diff --git a/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdPolicy.swift b/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdPolicy.swift new file mode 100644 index 0000000..dfc49d0 --- /dev/null +++ b/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdPolicy.swift @@ -0,0 +1,81 @@ +import Foundation + +enum BannerPlacement: String, CaseIterable { + case characterList = "character_list" + case moveList = "move_list" + case memoList = "memo_list" + case settings +} + +struct BannerAdPolicy { + var enabled = false + var visible = false + var keyboardVisible = false + var editing = false + var consentAllowsAds = false + + var canLoad: Bool { + enabled && visible && !keyboardVisible && !editing && consentAllowsAds + } +} + +struct BannerAdConfiguration { + static let sampleAppID = "ca-app-pub-3940256099942544~1458002511" + static let sampleBannerID = "ca-app-pub-3940256099942544/2934735716" + static let sampleNativeID = "ca-app-pub-3940256099942544/3986624511" + static let remoteKey = "admob_banner_enabled" + + let isDebug: Bool + let isTesting: Bool + let appID: String + let bannerID: String + let nativeID: String + let localTestEnabled: Bool + + var adUnitID: String? { + guard !isTesting else { return nil } + if isDebug { return Self.sampleBannerID } + guard Self.isValidID(appID, separator: "~"), + Self.isValidID(bannerID, separator: "/"), + !appID.contains("3940256099942544"), + !bannerID.contains("3940256099942544") else { return nil } + return bannerID + } + + var nativeAdUnitID: String? { + guard !isTesting else { return nil } + if isDebug { return Self.sampleNativeID } + guard Self.isValidID(appID, separator: "~"), + Self.isValidID(nativeID, separator: "/"), + !appID.contains("3940256099942544"), + !nativeID.contains("3940256099942544") else { return nil } + return nativeID + } + + var hasAnyAdUnitID: Bool { adUnitID != nil || nativeAdUnitID != nil } + var usesLocalTestAds: Bool { isDebug && localTestEnabled && !isTesting } + /// Debug uses Google's sample unit; Release requires the app's real IDs. + var usesNativeMoveAds: Bool { !isTesting && nativeAdUnitID != nil } + + private static func isValidID(_ value: String, separator: String) -> Bool { + value.range(of: "^ca-app-pub-[0-9]{16}" + separator + "[0-9]{10}$", options: .regularExpression) != nil + } + + static var current: Self { + #if DEBUG + let isDebug = true + #else + let isDebug = false + #endif + return Self( + isDebug: isDebug, + isTesting: ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil, + appID: Bundle.main.object(forInfoDictionaryKey: "GADApplicationIdentifier") as? String ?? "", + bannerID: Bundle.main.object(forInfoDictionaryKey: "AdMobBannerAdUnitID") as? String ?? "", + nativeID: Bundle.main.object(forInfoDictionaryKey: "AdMobNativeAdUnitID") as? String ?? "", + // Debug builds must always render Google's test creative so the banner + // layout can be inspected without enabling production monetization. + localTestEnabled: isDebug + ) + } +} diff --git a/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdService.swift b/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdService.swift new file mode 100644 index 0000000..9038edd --- /dev/null +++ b/Tekken8 Frame Data/TK8/Utility/Ads/BannerAdService.swift @@ -0,0 +1,139 @@ +import Combine +import FirebaseCore +import FirebaseRemoteConfig +import GoogleMobileAds +import UIKit +import UserMessagingPlatform + +@MainActor +protocol BannerAdServing: AnyObject { + var configuration: BannerAdConfiguration { get } + var statePublisher: AnyPublisher<(Bool, Bool), Never> { get } + func prepare(from controller: UIViewController) async -> Bool +} + +@MainActor +final class BannerAdService: BannerAdServing { + @Published private(set) var enabled = false + @Published private(set) var privacyOptionsRequired = false + @Published private(set) var consentAllowsAds = false + + let configuration: BannerAdConfiguration + var statePublisher: AnyPublisher<(Bool, Bool), Never> { + $enabled.combineLatest($consentAllowsAds).eraseToAnyPublisher() + } + private var remoteConfig: RemoteConfig? + private var registration: ConfigUpdateListenerRegistration? + private var foregroundSubscription: AnyCancellable? + private var consentTask: Task? + private var didCheckConsent = false + private var didStartSDK = false + private var isRefreshing = false + + init(configuration: BannerAdConfiguration = .current) { + self.configuration = configuration + guard configuration.hasAnyAdUnitID else { return } + // Test ads must be independently verifiable even when the production Remote + // Config key is still false. This mode can only select Google's sample IDs. + if configuration.usesLocalTestAds { + enabled = true + } else if FirebaseApp.app() != nil { + let remote = RemoteConfig.remoteConfig() + remote.setDefaults([BannerAdConfiguration.remoteKey: NSNumber(value: false)]) + let settings = RemoteConfigSettings() + settings.minimumFetchInterval = 300 + settings.fetchTimeout = 10 + remote.configSettings = settings + remoteConfig = remote + enabled = false + registration = remote.addOnConfigUpdateListener { [weak self] update, error in + Task { @MainActor in + guard let self, error == nil, + update?.updatedKeys.contains(BannerAdConfiguration.remoteKey) == true else { return } + do { + _ = try await remote.activate() + self.enabled = remote[BannerAdConfiguration.remoteKey].boolValue + } catch { + self.enabled = false + } + } + } + } + foregroundSubscription = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + Task { @MainActor in await self?.refresh() } + } + Task { await refresh() } + } + + deinit { registration?.remove() } + + func refresh() async { + guard let remoteConfig, !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + do { + // Fetch on foreground, so returning users do not keep a stale ON switch. + _ = try await remoteConfig.fetch(withExpirationDuration: 0) + _ = try await remoteConfig.activate() + enabled = remoteConfig[BannerAdConfiguration.remoteKey].boolValue + } catch { + // On an unavailable config service prefer the normal ad-free app. + enabled = false + } + } + + func prepare(from controller: UIViewController) async -> Bool { + guard enabled, configuration.hasAnyAdUnitID else { return false } + if let consentTask { return await consentTask.value } + if didCheckConsent { return consentAllowsAds } + guard controller.viewIfLoaded?.window != nil, + configuration.usesLocalTestAds || controller.presentedViewController == nil else { return false } + + let task = Task { @MainActor [weak self, weak controller] () -> Bool in + guard let self, let controller else { return false } + if self.configuration.usesLocalTestAds { + // Debug always selects Google's sample ad units. Keep that local + // verification path independent of production UMP configuration. + if !self.didStartSDK { + self.didStartSDK = true + await MobileAds.shared.start() + } + self.didCheckConsent = true + self.consentAllowsAds = true + return self.enabled + } + do { + try await ConsentInformation.shared.requestConsentInfoUpdate(with: RequestParameters()) + guard self.enabled, controller.viewIfLoaded?.window != nil, + self.configuration.usesLocalTestAds || controller.presentedViewController == nil else { return false } + try await ConsentForm.loadAndPresentIfRequired(from: controller) + } catch { + // UMP can still authorize requests using a valid prior consent choice. + } + self.didCheckConsent = true + if self.enabled && ConsentInformation.shared.canRequestAds && !self.didStartSDK { + self.didStartSDK = true + await MobileAds.shared.start() + } + self.updateConsentState() + return self.enabled && self.consentAllowsAds + } + consentTask = task + let result = await task.value + consentTask = nil + return result + } + + func presentPrivacyOptions(from controller: UIViewController) async throws { + guard privacyOptionsRequired else { return } + consentAllowsAds = false // Destroy existing ads before changing the choice. + defer { updateConsentState() } + try await ConsentForm.presentPrivacyOptionsForm(from: controller) + } + + private func updateConsentState() { + privacyOptionsRequired = ConsentInformation.shared.privacyOptionsRequirementStatus == .required + consentAllowsAds = ConsentInformation.shared.canRequestAds + } +} diff --git a/Tekken8 Frame Data/TK8/Utility/Ads/NativeMoveAdLoader.swift b/Tekken8 Frame Data/TK8/Utility/Ads/NativeMoveAdLoader.swift new file mode 100644 index 0000000..1b01051 --- /dev/null +++ b/Tekken8 Frame Data/TK8/Utility/Ads/NativeMoveAdLoader.swift @@ -0,0 +1,140 @@ +import Combine +import GoogleMobileAds +import UIKit + +@MainActor +final class NativeMoveAdLoader: NSObject, NativeAdLoaderDelegate, NativeAdDelegate { + private let service: any BannerAdServing + private let analytics: AnalyticsClient + private var adLoaders: [Int: AdLoader] = [:] + private var loaderPlacements: [ObjectIdentifier: Int] = [:] + private var loadingPlacements = Set() + private weak var controller: UIViewController? + private var requestedPlacements = [Int]() + private var stateSubscription: AnyCancellable? + private var adsAreAllowed = false + private var requestGeneration = 0 + + private(set) var nativeAds: [Int: NativeAd] = [:] { + didSet { onAdsChanged?() } + } + var onAdsChanged: (() -> Void)? + + init(service: any BannerAdServing, analytics: AnalyticsClient) { + self.service = service + self.analytics = analytics + super.init() + stateSubscription = service.statePublisher + .sink { [weak self] enabled, consent in + self?.updateAdState(enabled: enabled, consent: consent) + } + } + + func load(placements: [Int], from controller: UIViewController) { + guard let adUnitID = service.configuration.nativeAdUnitID else { return } + self.controller = controller + requestedPlacements = placements + removeStalePlacements(keeping: Set(placements)) + for placement in placements where requestedPlacements.contains(placement) && nativeAds[placement] == nil && !loadingPlacements.contains(placement) { + loadingPlacements.insert(placement) + let generation = requestGeneration + Task { [weak self, weak controller] in + guard let self, let controller else { return } + let canRequest = await self.service.prepare(from: controller) + guard canRequest, + self.requestGeneration == generation, + self.requestedPlacements.contains(placement), + controller.viewIfLoaded?.window != nil else { + self.removeLoadingPlacement(placement, generation: generation) + return + } + let loader = AdLoader(adUnitID: adUnitID, rootViewController: controller, adTypes: [.native], options: nil) + loader.delegate = self + self.adLoaders[placement] = loader + self.loaderPlacements[ObjectIdentifier(loader)] = placement + loader.load(Request()) + } + } + } + + func nativeAd(for placement: Int) -> NativeAd? { nativeAds[placement] } + + func reset() { + requestGeneration += 1 + requestedPlacements.removeAll() + controller = nil + nativeAds.values.forEach { $0.delegate = nil } + nativeAds.removeAll() + adLoaders.values.forEach { $0.delegate = nil } + adLoaders.removeAll() + loaderPlacements.removeAll() + loadingPlacements.removeAll() + } + + func adLoader(_ adLoader: AdLoader, didReceive nativeAd: NativeAd) { + guard let placement = loaderPlacements[ObjectIdentifier(adLoader)] else { return } + removeLoader(for: placement) + guard adsAreAllowed, controller?.viewIfLoaded?.window != nil else { + nativeAd.delegate = nil + return + } + nativeAd.delegate = self + nativeAds[placement] = nativeAd + } + + func adLoader(_ adLoader: AdLoader, didFailToReceiveAdWithError error: Error) { + guard let placement = loaderPlacements[ObjectIdentifier(adLoader)] else { return } + analytics.log(.nativeAdLoadFailed(placement: .moveList, code: (error as NSError).code)) + removeLoader(for: placement) + } + + func nativeAdDidRecordImpression(_ nativeAd: NativeAd) { + guard adsAreAllowed, + nativeAds.values.contains(where: { $0 === nativeAd }) else { return } + analytics.log(.nativeAdImpression(placement: .moveList)) + } + + private func removeLoader(for placement: Int) { + if let loader = adLoaders.removeValue(forKey: placement) { + loader.delegate = nil + loaderPlacements.removeValue(forKey: ObjectIdentifier(loader)) + } + loadingPlacements.remove(placement) + } + + private func removeStalePlacements(keeping placements: Set) { + let staleNativeAds = nativeAds.filter { !placements.contains($0.key) } + staleNativeAds.values.forEach { $0.delegate = nil } + if !staleNativeAds.isEmpty { + nativeAds = nativeAds.filter { placements.contains($0.key) } + } + + let staleLoaders = adLoaders.keys.filter { !placements.contains($0) } + for placement in staleLoaders { + removeLoader(for: placement) + } + } + + private func removeLoadingPlacement(_ placement: Int, generation: Int) { + guard requestGeneration == generation else { return } + loadingPlacements.remove(placement) + } + + private func updateAdState(enabled: Bool, consent: Bool) { + let allowed = enabled && consent + guard allowed != adsAreAllowed else { return } + adsAreAllowed = allowed + + guard allowed else { + requestGeneration += 1 + nativeAds.values.forEach { $0.delegate = nil } + nativeAds.removeAll() + let placements = Array(adLoaders.keys) + placements.forEach(removeLoader(for:)) + return + } + + guard let controller, controller.viewIfLoaded?.window != nil else { return } + load(placements: requestedPlacements, from: controller) + } +} diff --git a/Tekken8 Frame Data/TK8/Utility/Analytics/AnalyticsClient.swift b/Tekken8 Frame Data/TK8/Utility/Analytics/AnalyticsClient.swift index 1f08907..8a70a63 100644 --- a/Tekken8 Frame Data/TK8/Utility/Analytics/AnalyticsClient.swift +++ b/Tekken8 Frame Data/TK8/Utility/Analytics/AnalyticsClient.swift @@ -62,6 +62,34 @@ struct TK8AnalyticsEvent: Equatable { let name: String let parameters: [String: TK8AnalyticsValue] + static func bannerImpression(placement: BannerPlacement) -> Self { + Self(name: "banner_ad_impression", parameters: [ + "ad_placement": .string(placement.rawValue), + "ad_format": .string("banner") + ]) + } + + static func bannerLoadFailed(placement: BannerPlacement, code: Int) -> Self { + Self(name: "banner_ad_load_failed", parameters: [ + "ad_placement": .string(placement.rawValue), + "ad_error_code": .integer(code) + ]) + } + + static func nativeAdImpression(placement: BannerPlacement) -> Self { + Self(name: "native_ad_impression", parameters: [ + "ad_placement": .string(placement.rawValue), + "ad_format": .string("native") + ]) + } + + static func nativeAdLoadFailed(placement: BannerPlacement, code: Int) -> Self { + Self(name: "native_ad_load_failed", parameters: [ + "ad_placement": .string(placement.rawValue), + "ad_error_code": .integer(code) + ]) + } + static func screenViewed(_ screen: TK8AnalyticsScreen) -> Self { Self( name: "screen_view", diff --git a/Tekken8 Frame Data/TK8/Utility/DIContainer.swift b/Tekken8 Frame Data/TK8/Utility/DIContainer.swift index 24cf71c..74991a6 100644 --- a/Tekken8 Frame Data/TK8/Utility/DIContainer.swift +++ b/Tekken8 Frame Data/TK8/Utility/DIContainer.swift @@ -13,6 +13,16 @@ final class DIContainer { private let userDefaultsManager: UserDefaultsManageable private let preference: CharacterLayoutPreference private let analytics: AnalyticsClient + @MainActor private lazy var ads = BannerAdService() + + @MainActor private func withBanner(_ controller: T, placement: BannerPlacement) -> T { + controller.bannerAdHost = BannerAdHost(service: ads, placement: placement, analytics: analytics) + return controller + } + + @MainActor func makeSettingViewController() -> SettingViewController { + withBanner(SettingViewController(analytics: analytics, ads: ads), placement: .settings) + } init(analytics: AnalyticsClient? = nil) { coreDataManager = CoreDataManager() @@ -26,30 +36,34 @@ final class DIContainer { @MainActor func makeCharacterListViewController() -> CharacterListViewController { let repository = DefaultCharacterRepository(manager: supabaseManager, coreData: coreDataManager) let viewModel = CharacterListViewModel(characterRepository: repository) - return CharacterListViewController( + return withBanner(CharacterListViewController( characterListViewModel: viewModel, container: self, preference: preference, analytics: analytics - ) + ), placement: .characterList) } @MainActor func makeMoveListViewController(character: Character) -> MoveListViewController { let repository = DefaultMoveRepository(manager: supabaseManager, coreData: coreDataManager) let viewModel = MoveListViewModel(moveRepository: repository) - return MoveListViewController( + let controller = MoveListViewController( character: character, moveListViewModel: viewModel, container: self, - analytics: analytics + analytics: analytics, + nativeAdService: ads ) + // A configured native unit replaces the move-list banner. Debug uses the + // Google sample unit; Release becomes eligible only with valid real IDs. + return ads.configuration.usesNativeMoveAds ? controller : withBanner(controller, placement: .moveList) } @MainActor func makeMemoListViewController(characterListViewModel: any CharacterSelectable) -> MemoListViewController { let repository = DefaultMemoRepository(coreDataManager: coreDataManager) let viewModel = MemoViewModel(memoRepository: repository) - return MemoListViewController( + return withBanner(MemoListViewController( viewModel: viewModel, characterListViewModel: characterListViewModel, analytics: analytics @@ -59,7 +73,7 @@ final class DIContainer { characterListViewModel: characterListViewModel, memo: memo ) - } + }, placement: .memoList) } @MainActor func makeMemoComposeViewController( diff --git a/Tekken8 Frame Data/TK8/Utility/View/BaseViewController.swift b/Tekken8 Frame Data/TK8/Utility/View/BaseViewController.swift index ae2adcc..7bf78eb 100644 --- a/Tekken8 Frame Data/TK8/Utility/View/BaseViewController.swift +++ b/Tekken8 Frame Data/TK8/Utility/View/BaseViewController.swift @@ -11,6 +11,7 @@ import UIKit class BaseViewController: UIViewController { var subscriptionSet: Set + var bannerAdHost: BannerAdHost? // MARK: Initializer @@ -34,6 +35,7 @@ class BaseViewController: UIViewController { configureKeyboardDismissOnTap() bindViewModel() navigationItem.hidesSearchBarWhenScrolling = false + bannerAdHost?.install(in: self) } override func viewWillAppear(_ animated: Bool) { @@ -47,6 +49,22 @@ class BaseViewController: UIViewController { super.viewDidAppear(animated) navigationItem.hidesSearchBarWhenScrolling = true + bannerAdHost?.appear() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + bannerAdHost?.disappear() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + bannerAdHost?.layout() + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + bannerAdHost?.setEditing(editing) } deinit { diff --git a/Tekken8 Frame Data/TK8Tests/BannerAdTests.swift b/Tekken8 Frame Data/TK8Tests/BannerAdTests.swift new file mode 100644 index 0000000..2d2111f --- /dev/null +++ b/Tekken8 Frame Data/TK8Tests/BannerAdTests.swift @@ -0,0 +1,202 @@ +import Combine +import GoogleMobileAds +import UIKit +import XCTest +@testable import TK8 + +final class BannerAdConfigurationTests: XCTestCase { + func testNativeMoveAdsUseTwentyMoveIntervalsAfterFourthMove() { + XCTAssertEqual(MoveListViewController.nativeAdPlacements(moveCount: 3), []) + XCTAssertEqual(MoveListViewController.nativeAdPlacements(moveCount: 4), [4]) + XCTAssertEqual(MoveListViewController.nativeAdPlacements(moveCount: 24), [4, 24]) + XCTAssertEqual(MoveListViewController.nativeAdPlacements(moveCount: 45), [4, 24, 44]) + } + + func testDebugNeverUsesProductionBannerID() { + let configuration = makeConfiguration(debug: true, testing: false) + XCTAssertEqual(configuration.adUnitID, BannerAdConfiguration.sampleBannerID) + XCTAssertEqual(configuration.nativeAdUnitID, BannerAdConfiguration.sampleNativeID) + } + + func testDebugWithRealAppIDStillUsesLocalTestAds() { + let configuration = BannerAdConfiguration( + isDebug: true, + isTesting: false, + appID: "ca-app-pub-1234567890123456~1234567890", + bannerID: "ca-app-pub-1234567890123456/1234567890", + nativeID: "ca-app-pub-1234567890123456/1234567890", + localTestEnabled: true + ) + + XCTAssertTrue(configuration.usesLocalTestAds) + XCTAssertEqual(configuration.adUnitID, BannerAdConfiguration.sampleBannerID) + XCTAssertEqual(configuration.nativeAdUnitID, BannerAdConfiguration.sampleNativeID) + } + + func testReleaseAndTestHostCannotUseLocalTestAds() { + XCTAssertFalse(makeConfiguration(debug: false, testing: false).usesLocalTestAds) + XCTAssertFalse(makeConfiguration(debug: true, testing: true).usesLocalTestAds) + } + + func testTestHostNeverRequestsAds() { + XCTAssertNil(makeConfiguration(debug: true, testing: true).adUnitID) + XCTAssertNil(makeConfiguration(debug: false, testing: true).adUnitID) + } + + func testReleaseRequiresRealAndWellFormedIDs() { + let releaseConfiguration = makeConfiguration(debug: false, testing: false) + XCTAssertNotNil(releaseConfiguration.adUnitID) + XCTAssertTrue(releaseConfiguration.usesNativeMoveAds) + for id in ["", "$(ADMOB_APP_ID)", BannerAdConfiguration.sampleAppID, "ca-app-pub-invalid"] { + let configuration = BannerAdConfiguration(isDebug: false, isTesting: false, appID: id, + bannerID: "ca-app-pub-1234567890123456/1234567890", nativeID: "ca-app-pub-1234567890123456/1234567890", localTestEnabled: false) + XCTAssertNil(configuration.adUnitID) + } + let sample = BannerAdConfiguration(isDebug: false, isTesting: false, + appID: "ca-app-pub-1234567890123456~1234567890", + bannerID: BannerAdConfiguration.sampleBannerID, nativeID: BannerAdConfiguration.sampleNativeID, localTestEnabled: true) + XCTAssertNil(sample.adUnitID) + XCTAssertFalse(sample.usesNativeMoveAds) + } + + func testEverySuppressionConditionPreventsLoading() { + let allowed = BannerAdPolicy(enabled: true, visible: true, consentAllowsAds: true) + XCTAssertTrue(allowed.canLoad) + var policy = allowed + policy.enabled = false + XCTAssertFalse(policy.canLoad) + policy = allowed; policy.visible = false + XCTAssertFalse(policy.canLoad) + policy = allowed; policy.keyboardVisible = true + XCTAssertFalse(policy.canLoad) + policy = allowed; policy.editing = true + XCTAssertFalse(policy.canLoad) + policy = allowed; policy.consentAllowsAds = false + XCTAssertFalse(policy.canLoad) + } + + func testAdEventContractContainsOnlyPlacementAndNumericFailure() { + XCTAssertEqual(TK8AnalyticsEvent.bannerImpression(placement: .moveList), + TK8AnalyticsEvent(name: "banner_ad_impression", parameters: [ + "ad_placement": .string("move_list"), "ad_format": .string("banner")])) + XCTAssertEqual(TK8AnalyticsEvent.bannerLoadFailed(placement: .memoList, code: 2), + TK8AnalyticsEvent(name: "banner_ad_load_failed", parameters: [ + "ad_placement": .string("memo_list"), "ad_error_code": .integer(2)])) + } + + func testNativeAdEventContractContainsOnlyPlacementAndNumericFailure() { + XCTAssertEqual(TK8AnalyticsEvent.nativeAdImpression(placement: .moveList), + TK8AnalyticsEvent(name: "native_ad_impression", parameters: [ + "ad_placement": .string("move_list"), "ad_format": .string("native")])) + XCTAssertEqual(TK8AnalyticsEvent.nativeAdLoadFailed(placement: .moveList, code: 2), + TK8AnalyticsEvent(name: "native_ad_load_failed", parameters: [ + "ad_placement": .string("move_list"), "ad_error_code": .integer(2)])) + } + + private func makeConfiguration(debug: Bool, testing: Bool) -> BannerAdConfiguration { + BannerAdConfiguration(isDebug: debug, isTesting: testing, + appID: "ca-app-pub-1234567890123456~1234567890", + bannerID: "ca-app-pub-1234567890123456/1234567890", nativeID: "ca-app-pub-1234567890123456/1234567890", localTestEnabled: false) + } +} + +@MainActor +final class BannerAdHostTests: XCTestCase { + private var service: StubBannerService! + private var controller: UIViewController! + private var content: UIView! + private var host: BannerAdHost! + private var analytics: RecordingAnalyticsClient! + private var requests: [BannerView] = [] + + override func setUp() async throws { + service = StubBannerService() + controller = UIViewController() + content = controller.view + analytics = RecordingAnalyticsClient() + host = BannerAdHost(service: service, placement: .moveList, analytics: analytics, + loadAd: { [weak self] in self?.requests.append($0) }, + isApplicationActive: { true }) + host.install(in: controller) + controller.view.frame = CGRect(x: 0, y: 0, width: 375, height: 667) + controller.view.layoutIfNeeded() + } + + override func tearDown() async throws { + host.disappear() + host = nil + controller = nil + content = nil + requests = [] + service = nil + analytics = nil + } + + func testRemoteOffCollapsesBannerAndIgnoresLateLoad() { + host.appear() + let banner = requests[0] + host.bannerViewDidReceiveAd(banner) + XCTAssertNotNil(banner.superview) + + service.state.send((false, true)) + host.bannerViewDidReceiveAd(banner) + host.bannerViewDidRecordImpression(banner) + XCTAssertNil(banner.superview) + XCTAssertTrue(analytics.events.isEmpty) + } + + func testEditingDestroysAdAndResumesWithNewRequest() { + host.appear() + let first = requests[0] + host.setEditing(true) + XCTAssertNil(first.superview) + host.layout() + XCTAssertEqual(requests.count, 1) + host.setEditing(false) + XCTAssertEqual(requests.count, 2) + XCTAssertFalse(first === requests[1]) + } + + func testFailureHasNoBlankSpaceOrAutomaticRetryLoop() { + host.appear() + let banner = requests[0] + host.bannerView(banner, didFailToReceiveAdWithError: NSError(domain: "test", code: 2)) + host.layout() + controller.view.layoutIfNeeded() + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(content.frame.height, controller.view.bounds.height) + XCTAssertEqual(analytics.events, [.bannerLoadFailed(placement: .moveList, code: 2)]) + } + + func testLeavingScreenRejectsLateImpressionAndLoad() { + host.appear() + let banner = requests[0] + host.disappear() + host.bannerViewDidReceiveAd(banner) + host.bannerViewDidRecordImpression(banner) + XCTAssertNil(banner.superview) + XCTAssertTrue(analytics.events.isEmpty) + } + + func testOnlySDKImpressionRecordsExposureOncePerCreative() { + host.appear() + let banner = requests[0] + host.bannerViewDidReceiveAd(banner) + XCTAssertTrue(analytics.events.isEmpty) + host.bannerViewDidRecordImpression(banner) + host.bannerViewDidRecordImpression(banner) + XCTAssertEqual(analytics.events.count, 1) + host.bannerViewDidReceiveAd(banner) // SDK auto-refresh delivers another creative. + host.bannerViewDidRecordImpression(banner) + XCTAssertEqual(analytics.events.count, 2) + } +} + +@MainActor +private final class StubBannerService: BannerAdServing { + let state = CurrentValueSubject<(Bool, Bool), Never>((true, true)) + let configuration = BannerAdConfiguration(isDebug: true, isTesting: false, + appID: BannerAdConfiguration.sampleAppID, bannerID: "", nativeID: "", localTestEnabled: true) + var statePublisher: AnyPublisher<(Bool, Bool), Never> { state.eraseToAnyPublisher() } + func prepare(from controller: UIViewController) async -> Bool { true } +} diff --git a/docs/analytics-measurement.md b/docs/analytics-measurement.md index d4c0809..9ceb850 100644 --- a/docs/analytics-measurement.md +++ b/docs/analytics-measurement.md @@ -20,7 +20,7 @@ Firebase의 자동 `screen_view` swizzling은 `Info.plist`의 `FirebaseAutomatic | `move_filter` | `FilterView.onAppear` | | `onboarding` | `OnboardingViewController.viewDidAppear` | -운영 빌드는 수집을 켠다. `DEBUG` 빌드는 기본적으로 수집을 끄고, 실행 인자 `-FIRAnalyticsDebugEnabled`가 있을 때만 수집한다. DebugView 검증은 이 인자를 붙인 빌드에서 수행하며, 일반 Debug 실행 이벤트가 운영 집계에 섞이지 않도록 한다. 일반 Debug 및 테스트 호스트에서는 Firebase를 초기화하지 않고 NoOp 클라이언트를 사용한다. Info.plist의 초기 수집값은 NO이며, 허용된 실행에서만 초기화 후 수집을 켠다. 온보딩이 캐릭터 목록을 덮는 동안 메모 버튼 노출을 기록하지 않고, 닫힌 뒤 화면명과 노출을 복원한다. +운영 빌드는 수집을 켠다. `DEBUG` 빌드는 기본적으로 수집을 끄고, 실행 인자 `-FIRAnalyticsDebugEnabled`가 있을 때만 수집한다. DebugView 검증은 이 인자를 붙인 빌드에서 수행하며, 일반 Debug 실행 이벤트가 운영 집계에 섞이지 않도록 한다. Debug는 Google 테스트 광고를 위해 Firebase를 초기화하지만 Analytics 수집은 계속 끈다. 테스트 호스트는 광고 요청과 Analytics 모두 하지 않는다. Info.plist의 초기 수집값은 NO이며, 허용된 실행에서만 활성화한다. 온보딩이 캐릭터 목록을 덮는 동안 메모 버튼 노출을 기록하지 않고, 닫힌 뒤 화면명과 노출을 복원한다. ## 이벤트 계약 @@ -41,9 +41,15 @@ Firebase의 자동 `screen_view` swizzling은 `Info.plist`의 `FirebaseAutomatic | `memo_save_succeeded` | 메모 create/update의 영속 저장 성공 직후 | memo_mode=`create`\|`edit` | | `memo_save_failed` | 메모 create/update가 실패한 뒤 | `memo_mode`, `failure_code=repository_error` | | `memo_save_skipped` | 빈 메모 또는 변경 없는 편집을 저장하지 않고 종료할 때 | `memo_mode`, reason=`empty_content`\|`no_changes` | +| `banner_ad_impression` | SDK가 화면에 표시된 배너의 impression을 실제 기록할 때 | `ad_placement`, `ad_format=banner` | +| `banner_ad_load_failed` | SDK가 현재 화면의 배너 요청 실패를 알릴 때 | `ad_placement`, `ad_error_code` | +| `native_ad_impression` | SDK가 기술표 네이티브 카드의 impression을 실제 기록할 때 | `ad_placement=move_list`, `ad_format=native` | +| `native_ad_load_failed` | SDK가 기술표 네이티브 카드 요청 실패를 알릴 때 | `ad_placement=move_list`, `ad_error_code` | `character_id`는 사용자 입력이 아닌 Supabase 캐릭터의 영문 이름을 소문자로 정규화한 안정 식별자다. `search_scope` 값은 `character_list`, `move_list`, `memo_list` 중 하나다. +`ad_placement`는 `character_list`, `move_list`, `memo_list`, `settings` 중 하나다. 광고 이벤트에는 광고 내용, 클릭 대상, 사용자 식별자, SDK 오류 문구를 전송하지 않는다. impression 이벤트는 SDK의 실제 callback에서만 기록하므로 화면 진입이나 광고 요청 수로 대체하지 않는다. + ## 중복 제거 및 경계 ### 검색 @@ -86,8 +92,8 @@ Firebase의 자동 `screen_view` swizzling은 `Info.plist`의 `FirebaseAutomatic Firebase Console의 Analytics > Custom Definitions에서 실제 보고서에 사용할 항목만 등록한다. `screen_view`의 Firebase 기본 화면 매개변수는 별도 등록 대상이 아니다. -- 맞춤 측정기준: `search_scope`, `character_id`, `memo_mode`, `failure_code`, `reason` -- 맞춤 측정항목: `query_length`, `result_count`, `move_count`, `active_filter_count`, `section_count`, `attribute_count`, `previous_active_filter_count` +- 맞춤 측정기준: `search_scope`, `character_id`, `memo_mode`, `failure_code`, `reason`, `ad_placement`, `ad_format` +- 맞춤 측정항목: `query_length`, `result_count`, `move_count`, `active_filter_count`, `section_count`, `attribute_count`, `previous_active_filter_count`, `ad_error_code` - `startup_range_active`, `guard_range_active`는 SDK에 정수 0/1로 전송하며 보고서에서 비교하려면 이벤트 범위 맞춤 측정기준으로 등록한다. 등록 전후에 이벤트 이름·매개변수 철자와 범위를 이 문서 및 코드의 생성자와 함께 검토한다. BigQuery export와 대시보드는 1차 계측에 포함하지 않으며, 실제 데이터가 누적된 뒤 후속 작업으로 구성한다. @@ -110,4 +116,12 @@ Firebase Console의 Analytics > Custom Definitions에서 실제 보고서에 사 4. 빠른 검색 입력, 검색 취소, 화면 재진입에서 중복 이벤트가 계약대로 줄어드는지 확인한다. 5. 확인 후 일반 Debug 실행에서는 해당 인자를 제거한다. 운영 데이터의 집계 검증은 배포 후 Analytics Events 보고서에서 별도로 한다. +## 광고 실험 운영 + +1. AdMob에 iOS 앱을 등록하고 실제 앱 ID와 banner/native ad unit ID를 Release build setting `ADMOB_APP_ID`, `ADMOB_BANNER_AD_UNIT_ID`, `ADMOB_NATIVE_AD_UNIT_ID`에 설정한다. 현재 두 광고 단위 ID는 설정돼 있고 앱 ID가 아직 없으므로, 값을 비워 두거나 Google 샘플 ID를 쓰는 동안 Release는 광고를 요청하지 않는다. +2. AdMob Privacy & messaging에서 필요한 UMP 메시지를 게시하고, Firebase Remote Config에 boolean `admob_banner_enabled`를 만든다. 배포 전 기본값은 `false`로 둔다. +3. Debug에서 Google 테스트 배너가 캐릭터·메모·설정 목록에만 보이고, 기술표에는 네 번째 기술 뒤부터 20개 간격의 네이티브 테스트 카드가 보이는지 확인한다. 위치별 광고 요청이 실패하면 해당 카드의 공간이 사라져야 한다. Release 기술표는 실제 App ID와 네이티브 광고 단위 ID가 유효할 때 같은 네이티브 카드 경로를 사용한다. Debug는 실제 App ID가 설정돼 있어도 Google 샘플 광고 단위를 사용하며 Remote Config와 운영 UMP 설정을 거치지 않는다. +4. 직접 사용해도 괜찮은 경우에만 Remote Config 값을 `true`로 변경해 배포된 앱에서 광고를 시작한다. 문제가 있으면 값을 `false`로 바꾼다. 앱은 foreground 재진입 또는 real-time config update 이후 광고와 공간을 제거한다. +5. 배포 전후로 같은 앱 버전 범위를 비교해 `banner_ad_impression`과 화면 방문·기술 목록 도달률을 보조 신호로 확인한다. 재방문 감소는 GA4의 retention 보고서에서 별도로 확인하며, 광고 이벤트만으로 광고가 원인이라고 단정하지 않는다. + 공식 참고: [Firebase 이벤트 기록](https://firebase.google.com/docs/analytics/ios/events), [화면 추적](https://firebase.google.com/docs/analytics/screenviews), [DebugView](https://firebase.google.com/docs/analytics/debugview), [GA4 퍼널 탐색](https://support.google.com/analytics/answer/9327974) diff --git a/docs/architecture.md b/docs/architecture.md index 7776a61..0b051db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,8 +49,8 @@ UIKit ViewController ------> SwiftUI Cell / Filter | 설정 저장 | UserDefaults 및 `UserDefaultsManageable` | | 이미지 캐시 | `NSCache` + 앱 Caches 디렉터리 + `URLSession` | | 다국어 | String Catalog(`Localizable.xcstrings`) + 프레임 데이터 규칙 기반 한/영 변환 | -| 분석 | Firebase Analytics | -| 광고 의존성 | Google Mobile Ads가 앱 타깃에 링크되어 있으나 현재 `TK8` Swift 소스에서 직접 import하거나 호출하지 않음 | +| 분석 | Firebase Analytics + Firebase Remote Config | +| 광고 | Google Mobile Ads의 적응형 하단 배너와 기술표의 네이티브 카드. UMP가 광고 요청을 허용한 뒤 요청하고 Remote Config로 표시를 중단할 수 있음 | | 테스트 | XCTest, in-memory Core Data, Supabase 경계 테스트 | | 의존성 관리 | Swift Package Manager; 직접 의존성은 `supabase-swift`, `firebase-ios-sdk`, Google Mobile Ads package | | CI | GitHub Actions에서 SPM resolve 후 iOS Simulator용 clean build, Xcode Cloud에서 `main` 변경 시 Archive 및 TestFlight 전달 | @@ -74,7 +74,7 @@ UIKit ViewController ------> SwiftUI Cell / Filter │ │ │ ├── Network/ # Supabase adapter와 범용 URLSession 추상화 │ │ │ └── Persistant/ # Core Data/UserDefaults adapter (현재 폴더명 철자 유지) │ │ ├── Settings/ # 버전 정보, 문의, 후원/외부 링크 -│ │ ├── Utility/ # DI, 공통 View, 캐시, Analytics, extension, 상수 +│ │ ├── Utility/ # DI, 공통 View, 캐시, Analytics, Ads, extension, 상수 │ │ ├── Version/ # 앱 스키마·프레임 데이터 버전 비교와 캐시 무효화 │ │ ├── Assets.xcassets/ # 캐릭터·커맨드·색상·앱 아이콘 에셋 │ │ ├── Localizable.xcstrings # UI 현지화 문자열 @@ -95,7 +95,7 @@ UIKit ViewController ------> SwiftUI Cell / Filter | 모듈 | 주요 타입 | 책임 | |---|---|---| -| App/DI | `AppDelegate`, `SceneDelegate`, `DIContainer` | Firebase 초기화·수집 정책, 창/루트 navigation 구성, manager·repository·view model·view controller·analytics 조립 | +| App/DI | `AppDelegate`, `SceneDelegate`, `DIContainer` | Firebase 초기화·수집 정책, 창/루트 navigation 구성, manager·repository·view model·view controller·analytics·광고 서비스 조립 | | Character | `CharacterListViewController`, `CharacterListViewModel` | 캐릭터 조회/검색/정렬, list/grid 전환, 이미지 로딩, 기술·메모·설정 화면 진입 | | Move | `MoveListViewController`, `MoveListViewModel`, `TranslatorEngine` | 캐릭터별 기술 조회, 언어별 변환, 섹션 정렬, 키워드·속성·프레임 필터, diffable snapshot 구성 | | Memo | `MemoListViewController`, `MemoComposeViewController`, `MemoViewModel` | 로컬 메모 CRUD, pin/검색, 캐릭터 연결, `.tk8memos` 백업 import/export | @@ -103,7 +103,7 @@ UIKit ViewController ------> SwiftUI Cell / Filter | Repository/Network | `SupabaseManager` | `character`, `move`, `frame_data_version`, `tekken_version` 조회 | | Repository/Persistant | `CoreDataManager`, `UserDefaultsManager`, `CharacterLayoutPreference` | Core Data context/save/fetch/delete, 버전·온보딩·목록 레이아웃 설정 보존 | | Version | `VersionManager` | 로컬 데이터 스키마 및 서버 프레임 데이터 버전을 비교해 캐릭터/기술 캐시 무효화 | -| Utility | `ImageCacheManager`, `AnalyticsClient`, `SearchAnalyticsTracker`, base view/controller, extensions | 이미지 메모리/디스크 캐시, Firebase 이벤트 어댑터, 수동 화면 추적, 검색 디바운스·중복 제거, 공통 UI 생명주기, command parsing·localization 보조 | +| Utility | `ImageCacheManager`, `AnalyticsClient`, `SearchAnalyticsTracker`, `BannerAdService`, `BannerAdHost`, base view/controller, extensions | 이미지 메모리/디스크 캐시, Firebase 이벤트 어댑터, 수동 화면 추적, 검색 디바운스·중복 제거, Remote Config·UMP 기반 광고 허용 판정, 공통 UI 생명주기, command parsing·localization 보조 | | Data tooling | `import_moves_to_supabase.py`, apply shell scripts | 캐릭터별 CSV 검증, `sort_order` 계산, `(character_name, move_key)` 기준 Supabase upsert | ## 저장 데이터의 소유권 @@ -146,11 +146,17 @@ SceneDelegate `FirebaseAutomaticScreenReportingEnabled`를 끄고 UIKit 화면의 `viewDidAppear`와 SwiftUI 필터의 `onAppear`에서 `AnalyticsClient`를 통해 안정적인 영문 `screen_view`를 수동 기록한다. 운영 빌드는 Firebase 수집을 사용하고, Debug 빌드는 `-FIRAnalyticsDebugEnabled` 실행 인자가 있을 때만 수집한다. 일반 Debug 및 테스트 호스트에서는 Firebase 초기화를 건너뛰고 DI에서 NoOp 클라이언트를 사용한다. 초기 수집값은 Info.plist에서 NO이며 허용된 실행에서만 활성화한다. 온보딩 종료 콜백으로 아래 화면의 분석 문맥과 버튼 노출을 복원한다. -### 2. 분석 이벤트 +### 2. 광고 표시와 원격 중단 + +`DIContainer`는 하나의 `BannerAdService`를 만들고 캐릭터 목록·기술 목록·메모 목록·설정 ViewController에 `BannerAdHost`를 주입한다. Host는 기존 화면 view를 감싸 적응형 하단 배너만 배치하며, 광고가 실제로 로드되기 전·로드 실패·원격 중단 시에는 높이 0으로 접어 콘텐츠 영역을 남기지 않는다. 메모 작성 화면에는 Host를 주입하지 않는다. 검색 키보드가 보이거나 메모 목록이 편집 상태이면 Host가 광고를 즉시 제거하고, 화면 이탈·백그라운드·폭 변경 뒤 늦게 도착한 SDK 콜백도 무시한다. 기술표는 하단 배너 대신 `NativeMoveAdLoader`가 위치별로 독립된 네이티브 광고를 요청하고, 성공한 경우 네 번째 기술 카드 뒤부터 20개 간격으로 SDK 자산 카드를 넣는다. 화면 이탈 시 로더는 보유 광고·진행 중 요청·delegate를 비우고 snapshot에서 광고 카드를 제거한다. 필터 결과가 바뀌면 더 이상 필요한 위치가 아닌 광고와 로더도 정리한다. Debug는 Google 네이티브 테스트 ID를 사용하고, Release는 실제 App ID와 네이티브 광고 단위 ID가 모두 유효할 때만 같은 경로를 사용한다. 위치별 요청이 실패하면 해당 카드와 빈 공간을 모두 남기지 않는다. + +`BannerAdService`는 Firebase Remote Config의 `admob_banner_enabled`가 `true`일 때만 UMP 동의 정보를 갱신하고, `canRequestAds`가 true가 된 뒤 Google Mobile Ads SDK를 시작한다. 설정 화면은 UMP가 요구할 때만 `광고 개인정보 설정` 항목을 표시한다. 앱이 foreground가 될 때 Remote Config를 다시 가져오고 real-time update를 구독한다. 응답 실패나 앱 ID·광고 단위 ID 미설정 시에는 광고를 끈 상태로 유지한다. Debug는 실제 App ID 설정 여부와 관계없이 Google 샘플 광고 단위를 사용하고 Remote Config·운영 UMP 설정과 독립적으로 테스트 광고를 요청한다. Release는 유효한 실제 ID가 제공되기 전에는 광고를 요청하지 않는다. + +### 3. 분석 이벤트 화면·사용자 액션은 ViewController/SwiftUI 필터에서 `AnalyticsClient`에 타입화된 `TK8AnalyticsEvent`를 전달한다. Firebase SDK 호출과 파라미터 변환은 `Utility/Analytics/AnalyticsClient.swift`에만 둔다. 검색은 UI 갱신을 지연시키지 않고, 실제 결과 snapshot 반영 뒤 `SearchAnalyticsTracker`가 검색 시도별 로컬 UUID로 오래된 완료를 거르고 500ms 디바운스와 동일 조건 중복 제거를 적용한다. 기술 목록 표시 이벤트는 snapshot 완료와 화면 노출을 모두 확인한다. 메모 저장은 `MemoComposeViewController`가 MemoViewModel의 onPersisted 콜백으로 repository 쓰기 성공 직후 성공 이벤트를 기록하고, 빈 내용·변경 없음·repository 오류를 서로 다른 계약으로 보낸다. 저장은 실제 pop 완료 후 수행하며 목록 재조회 실패는 저장 실패로 집계하지 않는다. 상세 이벤트 목록과 지표 분모/분자는 `docs/analytics-measurement.md`를 기준으로 한다. -### 3. 캐릭터 목록 +### 4. 캐릭터 목록 1. `CharacterListViewController`가 `fetchCharacters()`를 요청한다. 2. `DefaultCharacterRepository`가 Core Data의 `CharacterEntity`를 먼저 조회한다. @@ -159,7 +165,7 @@ SceneDelegate 5. ViewModel이 기기 언어 기준으로 정렬하고 `@Published` 상태를 갱신한다. 6. ViewController가 Combine 구독을 통해 diffable data source snapshot을 적용한다. -### 4. 기술 목록과 필터 +### 5. 기술 목록과 필터 1. 캐릭터 선택 시 `DIContainer`가 해당 캐릭터용 `MoveListViewController`와 `DefaultMoveRepository`를 만든다. 2. Repository가 캐릭터 이름으로 `MoveEntity`를 `sortOrder` 오름차순 조회한다. @@ -168,15 +174,15 @@ SceneDelegate 5. 키워드, 섹션, 속성, 발동/가드 프레임 조건을 적용하고 섹션 및 `sortOrder` 기준으로 정렬한다. 6. UIKit collection view가 SwiftUI `MoveCell`을 `UIHostingConfiguration`으로 렌더링한다. -### 5. 캐릭터 이미지 +### 6. 캐릭터 이미지 `CharacterListViewModel`은 먼저 Asset Catalog에서 캐릭터 영문 이름과 같은 이미지를 찾는다. 로컬 에셋이 없으면 `Character.imageURL`의 HTTP(S) URL을 `ImageCacheManager`에 요청한다. 캐시는 `NSCache`, Caches 디렉터리, 네트워크 순으로 조회된다. 이미지 URL의 호스팅 제공자는 `character` 데이터가 소유하므로 앱 코드는 Supabase Storage 경로를 조합하지 않는다. -### 6. 메모와 백업 +### 7. 메모와 백업 메모는 원격 서버를 사용하지 않는다. `MemoViewModel`이 `DefaultMemoRepository`를 통해 `MemoEntity`를 직접 CRUD하며 최신 수정일 순으로 읽는다. export는 전체 메모를 앱 전용 JSON 문서(`.tk8memos`)로 인코딩하고, import는 UUID가 같은 메모 중 가져온 `updatedAt`이 더 최신인 항목만 갱신한다. -### 7. 프레임 데이터 운영 흐름 +### 8. 프레임 데이터 운영 흐름 ```text scripts/data/moves/.csv @@ -201,12 +207,12 @@ frame_data_version 증가 ## 테스트와 검증 경계 -- `TK8Tests`: 모델 decoding/hash, command tokenization, 한/영 번역, 기술 필터, 메모 CRUD와 백업 merge, Analytics 이벤트 계약·검색 디바운스·저장 판정을 검증한다. +- `TK8Tests`: 모델 decoding/hash, command tokenization, 한/영 번역, 기술 필터, 메모 CRUD와 백업 merge, Analytics 이벤트 계약·검색 디바운스·저장 판정, 배너 광고 정책·실패·늦은 SDK 응답 처리를 검증한다. - `SupabaseAPITests`: `Character.swift`, `Move.swift`, `SupabaseManageable.swift`, 버전 모델을 테스트 target의 파일 동기화 예외로 직접 포함하고 mock을 사용해 Supabase adapter 경계를 검증한다. 앱 모듈 import에 의존하지 않아 앱의 Firebase/기타 패키지 의존성이 경계 테스트에 전파되지 않는다. - CI의 `swift.yml`은 SPM 의존성을 해석하고 `Tekken8 Frame Data` scheme을 Simulator 대상으로 clean build한다. 현재 workflow에는 테스트 실행 단계가 별도로 없다. - Xcode Cloud release workflow는 `main` 변경 시 Archive한다. `ci_scripts/ci_post_clone.sh`가 `CI_PRIMARY_REPOSITORY_PATH`의 실제 checkout 위치를 기준으로 workflow의 secret 환경변수 `API_KEY`, `SUPABASE_URL`를 추적되지 않는 `TK8/Secrets.xcconfig`에 원자적으로 기록한 뒤 Archive가 진행된다. 둘 중 하나라도 누락되면 스크립트가 실패해 잘못된 설정의 배포를 막는다. 기존 설정 파일이 있어도 최종 권한은 `600`으로 강제한다. `API_KEY`는 `sb_publishable_` key 또는 `role=anon` legacy JWT만 허용하며, secret/service-role key는 사용하지 않는다. Firebase Analytics 초기화에 필요한 `TK8/GoogleService-Info.plist`는 `FIREBASE_GOOGLE_SERVICE_INFO_PLIST_BASE64` secret environment variable을 post-clone 단계에서 Base64 복원한다. 복원 파일은 plist 문법과 `BUNDLE_ID=com.moongoon.TK8`을 검증하고 권한 `600`으로 원자적으로 교체한다. 따라서 Firebase configuration은 Git에 추적하지 않는다. - Core Data 관련 테스트는 in-memory persistent store를 사용한다. -- Analytics 콘솔의 실제 사용자 수·전환율·DebugView 수집 상태는 로컬 테스트 범위에 포함하지 않는다. 앱 DebugView 점검 절차와 맞춤 정의 대상은 `docs/analytics-measurement.md`에 기록한다. +- Analytics 콘솔의 실제 사용자 수·전환율·DebugView 수집 상태와 AdMob 수익·실제 재방문 지표는 로컬 테스트 범위에 포함하지 않는다. 앱 DebugView 점검 절차와 맞춤 정의 대상은 `docs/analytics-measurement.md`에 기록한다. ## 구조 변경 시 동기화 대상