<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged admob</title>
<link>https://coderlegion.com/tag/admob</link>
<description>Powered by Question2Answer</description>
<item>
<title>AdMob Earnings Dashboard for iOS: OAuth with PKCE, AdMob Reporting API and Offline Caching</title>
<link>https://coderlegion.com/32550/admob-earnings-dashboard-for-ios-oauth-with-pkce-admob-reporting-api-and-offline-caching</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Signing in with ASWebAuthenticationSession and PKCE, storing tokens safely in the Keychain, calling networkReport:generate, decoding a streamed report, turning micros into money, why &quot;today&quot; is a Los Angeles day, live earning rates, and caching for offline use&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-icon-512.jpg&quot; alt=&quot;AdEarnings Pro app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;If you have apps with ads, you know the habit: open the AdMob website on your phone, pinch, zoom, wait for the dashboard, then do it again an hour later. I wanted a native, fast, glanceable view of my AdMob earnings, so I built &lt;strong&gt;AdEarnings Pro&lt;/strong&gt;. It's a dashboard for today's earnings, yesterday's comparison, earning rates per second, minute and hour, weekly averages for earnings and clicks, six months of history and currency conversion. It uses Google Sign-In, and the listing states plainly: &quot;Your AdMob data is not saved, shared, or accessible to us.&quot;&lt;/p&gt;
&lt;p&gt;That promise shapes the whole architecture. There's no server of mine in the middle. The app talks &lt;strong&gt;directly&lt;/strong&gt; to Google's AdMob API using the user's own OAuth token, and everything is cached on the device.&lt;/p&gt;
&lt;p&gt;This article walks through how to build that: OAuth for a native app done properly, the AdMob Reporting API's actual request and response format, and a few surprises from Google's documentation. The biggest is that the report time zone currently supports only &lt;code&gt;America/Los_Angeles&lt;/code&gt;, which changes what &quot;today&quot; means for everyone outside California. Apple APIs are checked against Apple's documentation and Google APIs against Google's. Where I describe my approach rather than the shipped code, I say so.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-shape-of-the-app-no-middleman&quot;&gt;1. The shape of the app: no middleman&lt;/h3&gt;
&lt;p&gt;The data flow is deliberately short:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The user signs in with Google and grants a &lt;strong&gt;read-only reporting scope&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The app stores the refresh token in the &lt;strong&gt;Keychain&lt;/strong&gt;, on the device.&lt;/li&gt;
&lt;li&gt;The app calls &lt;code&gt;admob.googleapis.com&lt;/code&gt; &lt;strong&gt;directly&lt;/strong&gt; with a short-lived access token.&lt;/li&gt;
&lt;li&gt;Results are cached locally for offline viewing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Google's AdMob API getting-started guide lists two scopes. &lt;code&gt;admob.readonly&lt;/code&gt; lets an app &quot;See all AdMob data&quot;, and Google notes that this &quot;doesn't include sensitive data, such as payments or campaign details&quot;. &lt;code&gt;admob.report&lt;/code&gt; lets it &quot;See ad performance and earnings reports. See publisher ID, timezone, and default currency code.&quot; For an earnings dashboard, &lt;strong&gt;&lt;code&gt;admob.report&lt;/code&gt; is the narrowest scope that works&lt;/strong&gt;, and asking for the minimum is both good privacy and good for consent rates. Google's OAuth guide makes the same point: the fewer scopes you request, the more likely users are to grant them.&lt;/p&gt;
&lt;p&gt;The listing also notes &quot;This app requires access to your existing Google AdMob account&quot;. The API reads accounts; it can't create one.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-oauth-for-a-native-app-system-browser-pkce-no-embedded-web-views&quot;&gt;2. OAuth for a native app: system browser, PKCE, no embedded web views&lt;/h3&gt;
&lt;p&gt;Google's OAuth documentation is blunt about embedded web views. Opening the authorization page in &lt;code&gt;WKWebView&lt;/code&gt; produces a &lt;code&gt;disallowed_useragent&lt;/code&gt; error, and Google recommends its Google Sign-In for iOS library or AppAuth instead. Both are built on the same standard flow: the &lt;strong&gt;system browser&lt;/strong&gt; plus &lt;strong&gt;PKCE&lt;/strong&gt; (Proof Key for Code Exchange). Here's that flow with Apple's own &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; (iOS 12+), so you can see every step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AuthenticationServices
import UIKit
import CryptoKit
import Foundation

struct PKCE {
    let verifier: String
    let challenge: String

    /// Google: verifier is 43–128 chars of [A-Z a-z 0-9 - . _ ~]; S256 challenge = BASE64URL(SHA256(verifier)).
    static func make() -&amp;gt; PKCE {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &amp;amp;bytes)
        let verifier = Data(bytes).base64URLEncoded()                     // 43 chars
        let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
        return PKCE(verifier: verifier, challenge: challenge)
    }
}

extension Data {
    func base64URLEncoded() -&amp;gt; String {
        base64EncodedString().replacingOccurrences(of: &quot;+&quot;, with: &quot;-&quot;)
            .replacingOccurrences(of: &quot;/&quot;, with: &quot;_&quot;).replacingOccurrences(of: &quot;=&quot;, with: &quot;&quot;)
    }
}

@MainActor
final class GoogleAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
    // An iOS OAuth client in Google Cloud; its redirect uses the reversed client ID as the scheme.
    let clientID = &quot;1234-abc.apps.googleusercontent.com&quot;
    var redirectScheme: String { &quot;com.googleusercontent.apps.1234-abc&quot; }
    var redirectURI: String { &quot;\(redirectScheme):/oauth2redirect&quot; }

    func signIn() async throws -&amp;gt; (code: String, pkce: PKCE) {
        let pkce = PKCE.make()
        let state = UUID().uuidString
        var url = URLComponents(string: &quot;https://accounts.google.com/o/oauth2/v2/auth&quot;)!
        url.queryItems = [
            .init(name: &quot;client_id&quot;, value: clientID),
            .init(name: &quot;redirect_uri&quot;, value: redirectURI),
            .init(name: &quot;response_type&quot;, value: &quot;code&quot;),
            .init(name: &quot;scope&quot;, value: &quot;https://www.googleapis.com/auth/admob.report&quot;),
            .init(name: &quot;code_challenge&quot;, value: pkce.challenge),
            .init(name: &quot;code_challenge_method&quot;, value: &quot;S256&quot;),
            .init(name: &quot;state&quot;, value: state)
        ]

        let callback: URL = try await withCheckedThrowingContinuation { cont in
            let session = ASWebAuthenticationSession(url: url.url!, callbackURLScheme: redirectScheme) { url, error in
                if let url { cont.resume(returning: url) } else { cont.resume(throwing: error ?? URLError(.cancelled)) }
            }
            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = false   // reuse an existing Google session if there is one
            session.start()
        }

        let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
        guard items.first(where: { $0.name == &quot;state&quot; })?.value == state,       // CSRF check
              let code = items.first(where: { $0.name == &quot;code&quot; })?.value else { throw URLError(.userAuthenticationRequired) }
        return (code, pkce)
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -&amp;gt; ASPresentationAnchor {
        UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every parameter here comes from Google's OAuth documentation for installed apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The authorization endpoint&lt;/strong&gt; is &lt;code&gt;accounts.google.com/o/oauth2/v2/auth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;response_type=code&lt;/code&gt;&lt;/strong&gt; is required.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The challenge is S256&lt;/strong&gt;, which Google recommends.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The verifier is 43 to 128 characters.&lt;/strong&gt; Thirty-two random bytes, base64url-encoded, gives exactly 43.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;state&lt;/code&gt;&lt;/strong&gt; protects against cross-site request forgery. Google calls it out as important, and the code checks it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On iOS 17.4 and later, &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; also accepts a &lt;code&gt;Callback&lt;/code&gt; object (&lt;code&gt;ASWebAuthenticationSession.Callback&lt;/code&gt;). SwiftUI has a &lt;code&gt;webAuthenticationSession&lt;/code&gt; environment value from iOS 16.4. Either is a tidier wrapper around the same flow.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-exchanging-the-code-and-keeping-tokens-safe&quot;&gt;3. Exchanging the code and keeping tokens safe&lt;/h3&gt;
&lt;p&gt;Next, swap the code for tokens at &lt;code&gt;oauth2.googleapis.com/token&lt;/code&gt;, sending the PKCE &lt;strong&gt;verifier&lt;/strong&gt; (not the challenge). Google's docs note that &lt;code&gt;client_secret&lt;/code&gt; doesn't apply to iOS clients:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct TokenResponse: Decodable {
    let access_token: String
    let expires_in: Int
    let refresh_token: String?
    let scope: String
}

extension GoogleAuth {
    func exchange(code: String, pkce: PKCE) async throws -&amp;gt; TokenResponse {
        try await tokenRequest([
            &quot;client_id&quot;: clientID, &quot;code&quot;: code, &quot;code_verifier&quot;: pkce.verifier,
            &quot;grant_type&quot;: &quot;authorization_code&quot;, &quot;redirect_uri&quot;: redirectURI
        ])
    }

    func refresh(using refreshToken: String) async throws -&amp;gt; TokenResponse {
        try await tokenRequest([&quot;client_id&quot;: clientID, &quot;grant_type&quot;: &quot;refresh_token&quot;, &quot;refresh_token&quot;: refreshToken])
    }

    private func tokenRequest(_ form: [String: String]) async throws -&amp;gt; TokenResponse {
        var request = URLRequest(url: URL(string: &quot;https://oauth2.googleapis.com/token&quot;)!)
        request.httpMethod = &quot;POST&quot;
        request.setValue(&quot;application/x-www-form-urlencoded&quot;, forHTTPHeaderField: &quot;Content-Type&quot;)
        var body = URLComponents(); body.queryItems = form.map { URLQueryItem(name: $0.key, value: $0.value) }
        request.httpBody = body.percentEncodedQuery?.data(using: .utf8)
        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(TokenResponse.self, from: data)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then store the &lt;strong&gt;refresh token&lt;/strong&gt; in the Keychain, never in &lt;code&gt;UserDefaults&lt;/code&gt;, and mark it device-only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Security

enum TokenVault {
    static let account = &quot;admob.refresh&quot;

    static func save(_ token: String) {
        let base: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                   kSecAttrAccount as String: account]
        SecItemDelete(base as CFDictionary)
        var item = base
        item[kSecValueData as String] = Data(token.utf8)
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(item as CFDictionary, nil)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AfterFirstUnlockThisDeviceOnly&lt;/code&gt; lets a widget or a background refresh read the token after the first unlock, while keeping it out of backups and off other devices. Two of Google's notes are worth building in. First, check the returned &lt;code&gt;scope&lt;/code&gt;: users can grant less than you asked for, and the app should explain what's missing. Second, there are limits on how many refresh tokens are issued per client and user, so &lt;strong&gt;keep and reuse&lt;/strong&gt; the refresh token rather than signing in again each time.&lt;/p&gt;
&lt;p&gt;&quot;Revoke consent anytime&quot; maps to two things. The user can remove access from their Google Account settings, and the app can call &lt;code&gt;https://oauth2.googleapis.com/revoke?token=…&lt;/code&gt; on sign-out and then delete the Keychain item.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-asking-admob-for-a-report&quot;&gt;4. Asking AdMob for a report&lt;/h3&gt;
&lt;p&gt;The AdMob API's core endpoint is &lt;code&gt;accounts.networkReport.generate&lt;/code&gt;, a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;https://admob.googleapis.com/v1/accounts/{publisherId}/networkReport:generate&lt;/code&gt; with a &lt;code&gt;reportSpec&lt;/code&gt;. Here's a request for daily earnings and clicks over the last seven days:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportDate: Codable { let year: Int, month: Int, day: Int }

struct NetworkReportRequest: Encodable {
    struct Spec: Encodable {
        struct Range: Encodable { let startDate: ReportDate; let endDate: ReportDate }
        struct Localization: Encodable { let currencyCode: String; let languageCode: String }
        let dateRange: Range
        let dimensions: [String]
        let metrics: [String]
        let localizationSettings: Localization
        let timeZone: String
    }
    let reportSpec: Spec
}

func weeklyRequest(endingOn end: ReportDate, start: ReportDate, currency: String) -&amp;gt; NetworkReportRequest {
    .init(reportSpec: .init(
        dateRange: .init(startDate: start, endDate: end),
        dimensions: [&quot;DATE&quot;],
        metrics: [&quot;ESTIMATED_EARNINGS&quot;, &quot;CLICKS&quot;, &quot;IMPRESSIONS&quot;],
        localizationSettings: .init(currencyCode: currency, languageCode: &quot;en-US&quot;),
        timeZone: &quot;America/Los_Angeles&quot;
    ))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three facts from Google's reference page shape this request:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ESTIMATED_EARNINGS&lt;/code&gt; is in micros.&lt;/strong&gt; &quot;$6.50 would be represented as 6500000.&quot; Never put that number in a &lt;code&gt;Double&lt;/code&gt; and divide casually; use &lt;code&gt;Decimal&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Currency comes from &lt;code&gt;localizationSettings.currencyCode&lt;/code&gt;.&lt;/strong&gt; Google says the currency unit of the earnings metrics is &quot;determined by the localization setting for currency&quot;. The approach I'd use for &quot;View earnings in your preferred currency&quot; is to ask AdMob for that currency directly, so the figures are AdMob's own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The time zone currently has only one supported value.&lt;/strong&gt; Google: &quot;Warning: The 'America/Los_Angeles' is the only supported value at the moment.&quot; Section 6 is about what that means.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-decoding-a-streamed-report&quot;&gt;5. Decoding a streamed report&lt;/h3&gt;
&lt;p&gt;The response isn't a simple object. It's &quot;the result of a server-side streaming RPC&quot;: a JSON &lt;strong&gt;array&lt;/strong&gt; whose first element has a &lt;code&gt;header&lt;/code&gt;, then one &lt;code&gt;row&lt;/code&gt; per result, then a &lt;code&gt;footer&lt;/code&gt;. Each element has exactly one of those keys. So decode each element as an enum-like struct:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportChunk: Decodable {
    struct Row: Decodable {
        struct DimensionValue: Decodable { let value: String }
        struct MetricValue: Decodable { let microsValue: String?; let integerValue: String?; let doubleValue: Double? }
        let dimensionValues: [String: DimensionValue]
        let metricValues: [String: MetricValue]
    }
    let header: [String: AnyDecodable]?      // dateRange, localizationSettings
    let row: Row?
    let footer: [String: AnyDecodable]?      // matchingRowCount, warnings
}

struct AnyDecodable: Decodable { init(from decoder: Decoder) throws {} }   // we don't need header details here

struct DailyEarnings: Codable, Identifiable {
    var id: String { day }
    let day: String          // &quot;20261022&quot;
    let earnings: Decimal    // in report currency
    let clicks: Int
}

func parse(_ data: Data) throws -&amp;gt; [DailyEarnings] {
    try JSONDecoder().decode([ReportChunk].self, from: data).compactMap(\.row).map { row in
        let micros = Decimal(string: row.metricValues[&quot;ESTIMATED_EARNINGS&quot;]?.microsValue ?? &quot;0&quot;) ?? 0
        return DailyEarnings(day: row.dimensionValues[&quot;DATE&quot;]?.value ?? &quot;&quot;,
                             earnings: micros / 1_000_000,
                             clicks: Int(row.metricValues[&quot;CLICKS&quot;]?.integerValue ?? &quot;0&quot;) ?? 0)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google's example response shows &lt;code&gt;&quot;microsValue&quot;: 6500000&lt;/code&gt; as a bare number. The API's JSON mapping for 64-bit integers often uses strings, so I'd decode both forms in production (a small custom &lt;code&gt;init(from:)&lt;/code&gt;). The &lt;code&gt;DATE&lt;/code&gt; dimension value is &lt;code&gt;YYYYMMDD&lt;/code&gt;, as Google's reference describes.&lt;/p&gt;
&lt;p&gt;Displaying money is then &lt;code&gt;earnings.formatted(.currency(code: currency))&lt;/code&gt;. Don't hand-roll formats, because symbol placement and grouping vary by locale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-why-today-is-a-los-angeles-day&quot;&gt;6. Why &quot;today&quot; is a Los Angeles day&lt;/h3&gt;
&lt;p&gt;Here's the subtle part. If reports can only be in &lt;code&gt;America/Los_Angeles&lt;/code&gt;, then AdMob's &quot;today&quot; starts at &lt;strong&gt;midnight Pacific time&lt;/strong&gt;. For me in India, that's 12:30 or 1:30 in the afternoon, depending on US daylight saving. A dashboard that labels the latest row &quot;Today&quot; without explanation will confuse anyone outside the Pacific time zone.&lt;/p&gt;
&lt;p&gt;The approach I'd use is to compute &quot;today&quot; in the report's calendar and say so in the UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportCalendar {
    static let zone = TimeZone(identifier: &quot;America/Los_Angeles&quot;)!
    static var calendar: Calendar { var c = Calendar(identifier: .gregorian); c.timeZone = zone; return c }

    static func reportDate(for date: Date = .now) -&amp;gt; ReportDate {
        let c = calendar.dateComponents([.year, .month, .day], from: date)
        return ReportDate(year: c.year!, month: c.month!, day: c.day!)
    }

    /// Seconds elapsed in the current *report* day; the denominator for live rates.
    static func secondsIntoReportDay(now: Date = .now) -&amp;gt; TimeInterval {
        now.timeIntervalSince(calendar.startOfDay(for: now))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;live earning rates&lt;/strong&gt; in the listing (&quot;per second, minute, and hour&quot;) follow naturally. Today's earnings divided by seconds into the report day gives an average rate so far:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;func rates(todayEarnings: Decimal, now: Date = .now) -&amp;gt; (perSecond: Decimal, perMinute: Decimal, perHour: Decimal) {
    let seconds = Decimal(max(ReportCalendar.secondsIntoReportDay(now: now), 60))   // avoid tiny denominators just after midnight
    let perSecond = todayEarnings / seconds
    return (perSecond, perSecond * 60, perSecond * 3600)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Label it honestly, as &quot;average so far today&quot;. Ad revenue isn't evenly spread over a day, and AdMob's figures are &lt;em&gt;estimated&lt;/em&gt; earnings that can be revised.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-pull-to-refresh-offline-cache-and-the-weekly-view&quot;&gt;7. Pull to refresh, offline cache and the weekly view&lt;/h3&gt;
&lt;p&gt;&quot;Pull-to-refresh for fresh data&quot; is one modifier, &lt;code&gt;refreshable&lt;/code&gt; (iOS 15+). &quot;Offline-ready with cached data&quot; means saving the last good response, with a timestamp, and showing it whenever the network fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI

@MainActor
final class Dashboard: ObservableObject {
    @Published private(set) var days: [DailyEarnings] = []
    @Published private(set) var updatedAt: Date?
    private let cacheURL = URL.cachesDirectory.appending(path: &quot;report-week.json&quot;)

    init() {
        if let data = try? Data(contentsOf: cacheURL),
           let cached = try? JSONDecoder().decode(Cached.self, from: data) {
            days = cached.days; updatedAt = cached.at                   // instant first paint, even offline
        }
    }

    func refresh(using api: AdMobAPI) async {
        do {
            let fresh = try await api.lastSevenDays()
            days = fresh; updatedAt = .now
            try? JSONEncoder().encode(Cached(days: fresh, at: .now)).write(to: cacheURL, options: .atomic)
        } catch {
            // Keep showing the cache; the &quot;Updated 2 h ago&quot; label tells the user it's stale.
        }
    }

    var weeklyAverage: Decimal {
        days.isEmpty ? 0 : days.map(\.earnings).reduce(0, +) / Decimal(days.count)
    }

    private struct Cached: Codable { let days: [DailyEarnings]; let at: Date }
}

struct DashboardView: View {
    @StateObject private var model = Dashboard()
    let api: AdMobAPI
    var body: some View {
        List {
            if let updatedAt = model.updatedAt {
                Text(&quot;Updated \(updatedAt, format: .relative(presentation: .named))&quot;).font(.caption)
            }
            ForEach(model.days.reversed()) { day in
                LabeledContent(day.day, value: day.earnings, format: .currency(code: &quot;USD&quot;))
            }
        }
        .refreshable { await model.refresh(using: api) }
        .task { await model.refresh(using: api) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting the cache in &lt;strong&gt;Caches&lt;/strong&gt; is deliberate. It's disposable, it's re-fetchable, and it holds only aggregated numbers from the user's own account on the user's own device. That fits the listing's privacy statement. (&lt;code&gt;AdMobAPI&lt;/code&gt; here is the small wrapper around section 4's request, with a valid access token.)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-adearnings-pro&quot;&gt;8. Real-world example: AdEarnings Pro&lt;/h3&gt;
&lt;p&gt;This walkthrough comes from building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/adearnings-pro/id6754509445&quot;&gt;AdEarnings Pro&lt;/a&gt;&lt;/strong&gt;. The code above is the approach I'd use, written for this article, not a copy of the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-1.jpg&quot; alt=&quot;AdEarnings Pro App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Developer Tools&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;November 2025&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No ratings yet in the US store&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Secure Google Sign-In authentication&quot; / &quot;Google OAuth authentication is required to access AdMob API services&quot; &lt;/td&gt;&lt;td&gt;System-browser OAuth with PKCE, tokens in the Keychain (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Your AdMob data is not saved, shared, or accessible to us&quot; &lt;/td&gt;&lt;td&gt;Direct device-to-Google calls, no middleman (section 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;View today's earnings with live updates. Compare yesterday's performance.&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;networkReport:generate&lt;/code&gt; with the &lt;code&gt;DATE&lt;/code&gt; dimension (sections 4–5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Live earning rates per second, minute, and hour&quot; &lt;/td&gt;&lt;td&gt;Rates over the Pacific report day (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Weekly average trends for earnings and clicks&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ESTIMATED_EARNINGS&lt;/code&gt; and &lt;code&gt;CLICKS&lt;/code&gt; (sections 4 and 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Convert earnings to any currency…&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;localizationSettings.currencyCode&lt;/code&gt; (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Pull-to-refresh for fresh data&quot; / &quot;Offline-ready with cached data&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;refreshable&lt;/code&gt; and a timestamped cache (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Revoke consent anytime&quot; &lt;/td&gt;&lt;td&gt;Google Account settings and the revoke endpoint (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-2.jpg&quot; alt=&quot;AdEarnings Pro App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing also mentions unpaid earnings, last payment history and GDPR consent management. Those are outside this article's scope.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-an-oauth-powered-dashboard-a-checklist&quot;&gt;9. Before you ship an OAuth-powered dashboard: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use the narrowest scope that works&lt;/strong&gt;, and check the granted &lt;code&gt;scope&lt;/code&gt; in the token response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use the system browser, PKCE (S256) and a &lt;code&gt;state&lt;/code&gt; check.&lt;/strong&gt; No &lt;code&gt;WKWebView&lt;/code&gt; for sign-in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the refresh token in the Keychain&lt;/strong&gt;, device-only, and reuse it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sign-out revokes the token&lt;/strong&gt; and clears the Keychain and caches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handle money as &lt;code&gt;Decimal&lt;/code&gt; from micros&lt;/strong&gt;, formatted with the currency's own format.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the report time zone explicit.&lt;/strong&gt; &quot;Today&quot; is a Pacific day, and the UI says so.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Label rates and estimates honestly&lt;/strong&gt;: &quot;average so far&quot; and &quot;estimated&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Show cached data instantly&lt;/strong&gt;, with a visible &quot;Updated…&quot; time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Complete Google's OAuth app verification&lt;/strong&gt; if your scopes need it before public release. Google's docs describe an &quot;unverified app&quot; screen otherwise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No third-party analytics touches the earnings data.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;An earnings dashboard looks like a chart, but it's mostly about trust. Sign in the way Google and Apple both recommend, ask for the least access, keep tokens on the device, talk to the API directly, and be honest about what the numbers mean, including the surprising fact that AdMob's &quot;today&quot; is a Los Angeles day. Get those right, and a glance at your phone replaces a dozen trips to a desktop website.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AdEarnings Pro (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/adearnings-pro/id6754509445&quot;&gt;https://apps.apple.com/us/app/adearnings-pro/id6754509445&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the one metric you check most in your ad dashboard? Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32550/admob-earnings-dashboard-for-ios-oauth-with-pkce-admob-reporting-api-and-offline-caching</guid>
<pubDate>Mon, 26 Oct 2026 12:42:23 +0000</pubDate>
</item>
<item>
<title>Technical Post-Mortem: Debugging AdMob Impression Leaks in an Expo/Firebase Mobile App</title>
<link>https://coderlegion.com/19057/technical-post-mortem-debugging-admob-impression-leaks-in-an-expo-firebase-mobile-app</link>
<description>&lt;p&gt;As a mobile developer, shipping an app to production is only half the battle. The real complications begin when analyzing live telemetry and monetization data.&lt;/p&gt;
&lt;p&gt;I recently launched a personal intermittent fasting tracker built on React Native (Expo) and Firebase called LifeFast. The app uses an ad-supported model to offset background hosting and real-time community feed sync costs.&lt;/p&gt;
&lt;p&gt;After reviewing my first week of live AdMob metrics, I noticed a major bottleneck in the rendering lifecycle: a severe disconnect between Ad Requests and Actual Impressions.&lt;/p&gt;
&lt;p&gt;The Data Log&lt;br&gt;
Total Requests: 1.11K (Strong API connectivity)&lt;/p&gt;
&lt;p&gt;Match Rate: 70.03% (AdMob is successfully returning inventory)&lt;/p&gt;
&lt;p&gt;Impressions: 345 (The bottleneck)&lt;/p&gt;
&lt;p&gt;Out of ~770 successfully returned ad units, less than half are registering as viewed by users.&lt;/p&gt;
&lt;p&gt;Breakdown by Ad Unit Lifecycle&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Interstitials (eCPM: €1.50 | 44 Impressions)&lt;br&gt;
The eCPM here is the highest, but the volume is low.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Root Cause: The layout logic was attempting to load and display the interstitial simultaneously during screen state transitions. If the user navigates faster than the network request completes, the ad is dropped, resulting in a matched request but zero impressions.&lt;/p&gt;
&lt;p&gt;The Fix: Migrating to a pre-loading architecture using ad.load() immediately upon component mount, caching the unit globally, and only executing ad.show() during natural UX breakpoints.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;App Open Ads (eCPM: €1.46 | 139 Impressions)&lt;br&gt;
The Root Cause: The current trigger relies heavily on a cold app start. If a user backgrounds the app to check a message and resumes their fast tracking, the lifecycle event isn't consistently capturing the background-to-foreground transition.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Fix: Listening directly to React Native's AppState changes to ensure the ad logic evaluates state changes from background -&amp;gt; active.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Banner Ads (eCPM: €0.21 | 162 Impressions)&lt;br&gt;
The Root Cause: Standard fixed-size banners are occasionally clipping outside the viewport on varying Android aspect ratios, firing the request script but failing the viewability requirements.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Fix: Deprecating fixed widths in favor of Adaptive Banners that dynamically calculate the screen width container before requesting the asset.&lt;/p&gt;
&lt;p&gt;Marketing an Independent Project on a $0 Budget&lt;br&gt;
The other engineering challenge is organic discovery. Traditional social platforms have highly aggressive automated spam filters that auto-flag direct Play Store links, making standard community outreach unviable.&lt;/p&gt;
&lt;p&gt;To bypass this without a marketing budget, I am currently implementing a code-driven viral loop using react-native-share to let users natively export progress milestones to their personal networks, shifting the acquisition logic directly into the application codebase.&lt;/p&gt;
&lt;p&gt;For context on the layout implementation or to see how the live UI handles these ad placements, the application is live on the Google Play Store under &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.jane.ndirangu.lifefast&quot;&gt;LifeFast.&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If anyone has optimized the match-to-impression ratio inside an Expo ecosystem, I'd be interested in comparing lifecycle architectures in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19057/technical-post-mortem-debugging-admob-impression-leaks-in-an-expo-firebase-mobile-app</guid>
<pubDate>Wed, 27 May 2026 11:13:12 +0000</pubDate>
</item>
</channel>
</rss>