diff --git a/Emoney Info.xcodeproj/project.pbxproj b/Emoney Info.xcodeproj/project.pbxproj index 15d93c9..ba1e297 100644 --- a/Emoney Info.xcodeproj/project.pbxproj +++ b/Emoney Info.xcodeproj/project.pbxproj @@ -623,7 +623,7 @@ CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 6S5573WXX4; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -643,7 +643,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -666,7 +666,7 @@ CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 6S5573WXX4; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -686,7 +686,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Emoney Info/Classes/api/UnifiedNfcApi.swift b/Emoney Info/Classes/api/UnifiedNfcApi.swift index de16634..bd7d412 100755 --- a/Emoney Info/Classes/api/UnifiedNfcApi.swift +++ b/Emoney Info/Classes/api/UnifiedNfcApi.swift @@ -229,25 +229,12 @@ public class UnifiedNfcApi { if (uid == 0){ normal = false } - if (data.count > 10){ - let type = data[10] - debugLog(type) - switch type { - case 0x01: - riwayat.setProsesTipe(1) - riwayat.setTitle("payment".localizeString(string: self.langCode!)) - debugLog("Pembayaran") - case 0x00, 0x03: - riwayat.setProsesTipe(0) - riwayat.setTitle("topup".localizeString(string: self.langCode!)) - debugLog("Topup") - default: - riwayat.setProsesTipe(1) - riwayat.setTitle("payment".localizeString(string: self.langCode!)) - debugLog("Other") - } - - + if data.count >= 13 { + let transactionKind = self.felicaTransactionKind(for: [UInt8](data)) + riwayat.setProsesTipe(transactionKind.prosesTipe) + riwayat.setTitle(transactionKind.title.localizeString(string: self.langCode!)) + debugLog("signature: \(transactionKind.signature)") + debugLog(transactionKind.logLabel) } if let station = self.stationMap[uid]{ debugLog("station", station.name) @@ -391,6 +378,33 @@ public class UnifiedNfcApi { return Int(bytes.withUnsafeBytes { $0.load(as: Int32.self).bigEndian }) } } + + private func felicaTransactionKind(for bytes: [UInt8]) -> (prosesTipe: Int, title: String, logLabel: String, signature: String) { + let signatureBytes = Array(bytes[10...12]) + let signature = signatureBytes.map { String(format: "%02X", $0) }.joined(separator: " ") + + switch signatureBytes { + case [0x00, 0x02, 0x00], + [0x03, 0x01, 0x00]: + return (0, "topup", "Topup", signature) + case [0x01, 0x01, 0x01], + [0x01, 0x58, 0x01], + [0x03, 0x61, 0x01]: + return (1, "payment", "Pembayaran", signature) + default: + let type = bytes[10] + switch type { + case 0x00: + return (0, "topup", "Topup (fallback)", signature) + case 0x01: + return (1, "payment", "Pembayaran (fallback)", signature) + case 0x03: + return (1, "payment", "Pembayaran (fallback 0x03)", signature) + default: + return (1, "payment", "Other", signature) + } + } + } public func stopCheckCard(message : String){ apduRunner.sessionEx?.invalidate(errorMessage: message) diff --git a/Emoney Info/HistoryView.swift b/Emoney Info/HistoryView.swift index d70b4a6..ab52738 100644 --- a/Emoney Info/HistoryView.swift +++ b/Emoney Info/HistoryView.swift @@ -223,38 +223,133 @@ private struct BannerAdView: UIViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator(self) } - func makeUIView(context: Context) -> GADBannerView { - let banner = GADBannerView(adSize: adSize) + func makeUIView(context: Context) -> HistoryBannerView { + let banner = HistoryBannerView(adSize: adSize) banner.adUnitID = adUnitID banner.delegate = context.coordinator - banner.rootViewController = context.coordinator.findRootViewController() - banner.load(GADRequest()) + banner.onDidMoveToWindow = { [weak coordinator = context.coordinator] bannerView in + coordinator?.handleBannerDidMoveToWindow(bannerView) + } return banner } - func updateUIView(_ uiView: GADBannerView, context: Context) {} + func updateUIView(_ uiView: HistoryBannerView, context: Context) { + context.coordinator.attachRootViewControllerIfNeeded(to: uiView) + context.coordinator.loadBannerIfNeeded(uiView, reason: "updateUIView") + } // MARK: Coordinator final class Coordinator: NSObject, GADBannerViewDelegate { let parent: BannerAdView + private var retryWorkItem: DispatchWorkItem? + private var isAdLoaded = false + private var isLoadInFlight = false + private let retryDelay: TimeInterval = 20 + init(_ parent: BannerAdView) { self.parent = parent } + deinit { + retryWorkItem?.cancel() + } + func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + retryWorkItem?.cancel() + isLoadInFlight = false + isAdLoaded = true + log("banner loaded", bannerView: bannerView) DispatchQueue.main.async { self.parent.onAdLoaded?() } } func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + isLoadInFlight = false + isAdLoaded = false + log("banner failed", bannerView: bannerView, error: error) + scheduleRetry(for: bannerView) DispatchQueue.main.async { self.parent.onAdFailed?() } } - func findRootViewController() -> UIViewController? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first { $0.isKeyWindow }? - .rootViewController + func handleBannerDidMoveToWindow(_ bannerView: GADBannerView) { + attachRootViewControllerIfNeeded(to: bannerView) + loadBannerIfNeeded(bannerView, reason: "didMoveToWindow") } + + func attachRootViewControllerIfNeeded(to bannerView: GADBannerView) { + guard let rootViewController = findNearestViewController(from: bannerView) else { + log("root view controller not ready", bannerView: bannerView) + return + } + + if bannerView.rootViewController !== rootViewController { + bannerView.rootViewController = rootViewController + log("attached root view controller", bannerView: bannerView) + } + } + + func loadBannerIfNeeded(_ bannerView: GADBannerView, reason: String) { + guard !isAdLoaded, !isLoadInFlight else { return } + guard bannerView.window != nil else { + log("skip load: banner is not in window", bannerView: bannerView, reason: reason) + return + } + guard bannerView.rootViewController != nil else { + log("skip load: root view controller missing", bannerView: bannerView, reason: reason) + return + } + + retryWorkItem?.cancel() + isLoadInFlight = true + log("loading banner", bannerView: bannerView, reason: reason) + bannerView.load(GADRequest()) + } + + private func scheduleRetry(for bannerView: GADBannerView) { + retryWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self, weak bannerView] in + guard let self, let bannerView else { return } + self.attachRootViewControllerIfNeeded(to: bannerView) + self.loadBannerIfNeeded(bannerView, reason: "retry") + } + retryWorkItem = workItem + + log("scheduling retry in \(Int(retryDelay))s", bannerView: bannerView) + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay, execute: workItem) + } + + private func log(_ message: String, bannerView: GADBannerView, reason: String? = nil, error: Error? = nil) { + var parts = ["[AdMob][History]", message] + if let reason { + parts.append("reason=\(reason)") + } + parts.append("adUnitID=\(bannerView.adUnitID ?? "-")") + parts.append("visible=\(bannerView.window != nil)") + parts.append("loaded=\(isAdLoaded)") + parts.append("inFlight=\(isLoadInFlight)") + parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))") + + if let nsError = error as NSError? { + parts.append("errorDomain=\(nsError.domain)") + parts.append("errorCode=\(nsError.code)") + parts.append("error=\(nsError.localizedDescription)") + } + + debugLog(parts.joined(separator: " | ")) + } + + private func findNearestViewController(from view: UIView) -> UIViewController? { + sequence(first: view.next, next: { $0?.next }) + .first { $0 is UIViewController } as? UIViewController + } + } +} + +private final class HistoryBannerView: GADBannerView { + var onDidMoveToWindow: ((HistoryBannerView) -> Void)? + + override func didMoveToWindow() { + super.didMoveToWindow() + onDidMoveToWindow?(self) } } @@ -400,11 +495,19 @@ final class HistoryHostingController: UIViewController { } private func loadInterstitial() { + debugLog("[AdMob][HistoryInterstitial] loading interstitial | adUnitID=\(interstitialAdUnitID)") GADInterstitialAd.load( withAdUnitID: interstitialAdUnitID, request: GADRequest() - ) { [weak self] ad, _ in - // Store ad if loaded; ignore error — fallback to direct export + ) { [weak self] ad, error in + if let error = error as NSError? { + debugLog( + "[AdMob][HistoryInterstitial] failed | adUnitID=\(self?.interstitialAdUnitID ?? "-") | errorDomain=\(error.domain) | errorCode=\(error.code) | error=\(error.localizedDescription)" + ) + } else { + debugLog("[AdMob][HistoryInterstitial] loaded | adUnitID=\(self?.interstitialAdUnitID ?? "-")") + } + self?.interstitial = ad self?.interstitial?.fullScreenContentDelegate = self } @@ -645,6 +748,7 @@ extension HistoryHostingController: GADFullScreenContentDelegate { // Called when the interstitial is dismissed — proceed with export func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) { + debugLog("[AdMob][HistoryInterstitial] dismissed") interstitial = nil loadInterstitial() // pre-load for the next export attempt exportPDF() @@ -652,6 +756,10 @@ extension HistoryHostingController: GADFullScreenContentDelegate { // Called when the interstitial fails to present — fall back to direct export func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + let nsError = error as NSError + debugLog( + "[AdMob][HistoryInterstitial] present failed | errorDomain=\(nsError.domain) | errorCode=\(nsError.code) | error=\(nsError.localizedDescription)" + ) interstitial = nil exportPDF() } diff --git a/Emoney Info/HomeView.swift b/Emoney Info/HomeView.swift index fa51350..446ba2d 100644 --- a/Emoney Info/HomeView.swift +++ b/Emoney Info/HomeView.swift @@ -63,6 +63,10 @@ final class HomeViewController: UIViewController { // Ads private let promoCard = UIView() private var bannerView = GADBannerView() + private var bannerRetryWorkItem: DispatchWorkItem? + private var isBannerAdLoaded = false + private var isBannerLoadInFlight = false + private let bannerRetryDelay: TimeInterval = 20 // Dynamic layout: toggle based on ad load state private var lastTxTopNoAd: NSLayoutConstraint! @@ -106,6 +110,7 @@ final class HomeViewController: UIViewController { name: Notification.Name("refreshScreen"), object: nil ) + loadBannerAdIfNeeded(reason: "viewDidAppear") } override func viewDidDisappear(_ animated: Bool) { @@ -113,6 +118,10 @@ final class HomeViewController: UIViewController { NotificationCenter.default.removeObserver(self, name: Notification.Name("refreshScreen"), object: nil) } + deinit { + bannerRetryWorkItem?.cancel() + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() cardGradient.frame = cardView.bounds @@ -266,9 +275,55 @@ final class HomeViewController: UIViewController { } func loadBannerAd() { + loadBannerAdIfNeeded(reason: "external") + } + + private func loadBannerAdIfNeeded(reason: String) { + guard !isBannerAdLoaded, !isBannerLoadInFlight else { return } + guard isViewLoaded, view.window != nil else { + logAdMob("skip load: view is not visible", reason: reason) + return + } + + bannerRetryWorkItem?.cancel() + bannerView.rootViewController = self + isBannerLoadInFlight = true + logAdMob("loading banner", reason: reason) bannerView.load(GADRequest()) } + private func scheduleBannerRetry(after delay: TimeInterval = 20) { + bannerRetryWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + self?.loadBannerAdIfNeeded(reason: "retry") + } + bannerRetryWorkItem = workItem + + logAdMob("scheduling retry in \(Int(delay))s") + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func logAdMob(_ message: String, reason: String? = nil, error: Error? = nil) { + var parts = ["[AdMob][Home]", message] + if let reason { + parts.append("reason=\(reason)") + } + parts.append("adUnitID=\(bannerView.adUnitID ?? "-")") + parts.append("visible=\(viewIfLoaded?.window != nil)") + parts.append("loaded=\(isBannerAdLoaded)") + parts.append("inFlight=\(isBannerLoadInFlight)") + parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))") + + if let nsError = error as NSError? { + parts.append("errorDomain=\(nsError.domain)") + parts.append("errorCode=\(nsError.code)") + parts.append("error=\(nsError.localizedDescription)") + } + + debugLog(parts.joined(separator: " | ")) + } + private func setupLastTransaction() { lastTxHeader.text = L10n.lastTransaction lastTxHeader.font = Theme.Font.subtitle(weight: .bold) @@ -562,6 +617,10 @@ struct LastTransactionItem { extension HomeViewController: GADBannerViewDelegate { func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + bannerRetryWorkItem?.cancel() + isBannerLoadInFlight = false + isBannerAdLoaded = true + logAdMob("banner loaded") lastTxTopNoAd.isActive = false lastTxTopWithAd.isActive = true UIView.animate(withDuration: 0.3) { @@ -571,12 +630,16 @@ extension HomeViewController: GADBannerViewDelegate { } func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + isBannerLoadInFlight = false + isBannerAdLoaded = false + logAdMob("banner failed", error: error) lastTxTopWithAd.isActive = false lastTxTopNoAd.isActive = true UIView.animate(withDuration: 0.3) { self.promoCard.isHidden = true self.view.layoutIfNeeded() } + scheduleBannerRetry(after: bannerRetryDelay) } } diff --git a/Emoney Info/SceneDelegate.swift b/Emoney Info/SceneDelegate.swift index db627e6..31ddfab 100755 --- a/Emoney Info/SceneDelegate.swift +++ b/Emoney Info/SceneDelegate.swift @@ -156,7 +156,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func startAdMobSDKIfNeeded() { guard !hasStartedAdMob else { return } hasStartedAdMob = true -// GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = ["7b42e513861d6b0c9f07529b748930c0"] +// GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = ["ae454ddf2186e5ac7871bf705de41098"] GADMobileAds.sharedInstance().start { [weak self] _ in DispatchQueue.main.async { self?.homeVC?.loadBannerAd() diff --git a/Emoney Info/SettingsView.swift b/Emoney Info/SettingsView.swift index af13f11..dfa08e8 100644 --- a/Emoney Info/SettingsView.swift +++ b/Emoney Info/SettingsView.swift @@ -34,6 +34,10 @@ final class SettingsViewController: UIViewController { // Ad banner private let adContainer = UIView() private var bannerView = GADBannerView() + private var bannerRetryWorkItem: DispatchWorkItem? + private var isBannerAdLoaded = false + private var isBannerLoadInFlight = false + private let bannerRetryDelay: TimeInterval = 20 // Dynamic constraints — toggled on ad load/fail private var generalTopNoAd: NSLayoutConstraint! @@ -59,6 +63,15 @@ final class SettingsViewController: UIViewController { setupFooter() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + loadBannerAdIfNeeded(reason: "viewDidAppear") + } + + deinit { + bannerRetryWorkItem?.cancel() + } + // MARK: - ScrollView private func setupScrollView() { @@ -132,10 +145,55 @@ final class SettingsViewController: UIViewController { bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height)) ]) - bannerView.load(GADRequest()) lastBottomAnchor = adContainer.bottomAnchor } + private func loadBannerAdIfNeeded(reason: String) { + guard !isBannerAdLoaded, !isBannerLoadInFlight else { return } + guard isViewLoaded, view.window != nil else { + logAdMob("skip load: view is not visible", reason: reason) + return + } + + bannerRetryWorkItem?.cancel() + bannerView.rootViewController = self + isBannerLoadInFlight = true + logAdMob("loading banner", reason: reason) + bannerView.load(GADRequest()) + } + + private func scheduleBannerRetry(after delay: TimeInterval = 20) { + bannerRetryWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + self?.loadBannerAdIfNeeded(reason: "retry") + } + bannerRetryWorkItem = workItem + + logAdMob("scheduling retry in \(Int(delay))s") + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func logAdMob(_ message: String, reason: String? = nil, error: Error? = nil) { + var parts = ["[AdMob][Settings]", message] + if let reason { + parts.append("reason=\(reason)") + } + parts.append("adUnitID=\(bannerView.adUnitID ?? "-")") + parts.append("visible=\(viewIfLoaded?.window != nil)") + parts.append("loaded=\(isBannerAdLoaded)") + parts.append("inFlight=\(isBannerLoadInFlight)") + parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))") + + if let nsError = error as NSError? { + parts.append("errorDomain=\(nsError.domain)") + parts.append("errorCode=\(nsError.code)") + parts.append("error=\(nsError.localizedDescription)") + } + + debugLog(parts.joined(separator: " | ")) + } + // MARK: - General Section private func setupGeneralSection() { @@ -291,6 +349,10 @@ final class SettingsViewController: UIViewController { extension SettingsViewController: GADBannerViewDelegate { func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + bannerRetryWorkItem?.cancel() + isBannerLoadInFlight = false + isBannerAdLoaded = true + logAdMob("banner loaded") generalTopNoAd.isActive = false generalTopWithAd.isActive = true UIView.animate(withDuration: 0.3) { @@ -300,12 +362,16 @@ extension SettingsViewController: GADBannerViewDelegate { } func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + isBannerLoadInFlight = false + isBannerAdLoaded = false + logAdMob("banner failed", error: error) generalTopWithAd.isActive = false generalTopNoAd.isActive = true UIView.animate(withDuration: 0.3) { self.adContainer.isHidden = true self.view.layoutIfNeeded() } + scheduleBannerRetry(after: bannerRetryDelay) } }