Fixing the order of the UMP explainer and the ATT request on iOS

Jaemyeong Jin···9 min read
한국어

DailySudoku is a personal project that gives one Sudoku puzzle every day. It ships on the App Store, Google Play, and the web. To keep it free, I added banner ads and consent handling.

Here are the abbreviations used in this post. IDFA (Identifier for Advertisers) is Apple’s advertising identifier. ATT (App Tracking Transparency) is the Apple framework that asks for tracking permission, and UMP (User Messaging Platform) is the consent screen SDK from Google. GDPR (General Data Protection Regulation) is the EU privacy regulation, EEA stands for European Economic Area, and NPA (Non-Personalized Ads) means ads that are not personalized.

The ATT system alert leaves little room to change its wording. So I wanted a separate screen that explains the reason before the request, and I drafted an IDFA explainer message in the AdMob console in 8 locales. The console said that this explainer screen triggers the ATT alert. If the SDK requests ATT by itself, then with the current structure, where the app already requests ATT, the explainer might never be shown. I stopped publishing and started investigating. This was about three and a half weeks after the earlier work.

The design from a month before

I had connected ATT and audited it about a month earlier. The reasoning at the time was this. The UMP screen is skipped in some regions, and if ATT sits inside the UMP completion callback, ATT is skipped there too. At the time, I expected the UMP screen in regions under GDPR, the United Kingdom, and some US states. Korea is the main market, so the app needed to obtain the IDFA outside the EEA as well.

So the calling rule did not depend on the UMP screen. In the startup task of AppCoordinator, the call sat after refreshConsent() and before maybeStartAdMob(), and it applied to every user who was not adFree. The actual ATT request ran in its own Task, so it ran at the same time as UMP.

Ad requests used the more restrictive of the two answers. If either ATT was denied or UMP consent was missing, the request carried npa=1. Back then I checked the four combinations of ATT and UMP states with unit tests, and confirmed in the simulator that the ATT alert appears on first launch, including in a non-EEA locale.

Whichever shows first wins

The ATT request screen appears only when the status is .notDetermined. A call after the user has answered just returns the existing status. So if the app’s request shows first, the SDK’s explainer has no place to appear. If the SDK moves first, the explainer appears and ATT follows. Which one came first depended on how long the network took. The reversed order, where ATT appears first and the explainer comes after it, could happen as well.

The existing tests did not reveal this problem. I found it while preparing to publish the AdMob message.

The documents had no answer, so I opened the binary

The Google guide to UMP privacy integration on iOS showed how to create the explainer message. At the time I could not find a basis for who calls ATT, how a sample uses it, or what the execution order is. The Apple AppTrackingTransparency documentation had no answer about UMP either. One line of guidance in the console was not enough to justify changing the code.

So I looked at the UserMessagingPlatform binary directly.

UserMessagingPlatform 바이너리:
  _objc_msgSend$requestTrackingAuthorizationWithCompletionHandler:
  _objc_msgSend$trackingAuthorizationStatus
  "ATTrackingManager"   ← 문자열(동적 조회)

otool -L: AppTrackingTransparency 하드 링크 없음

The Korean labels in the block read “binary” (바이너리), “string, dynamic lookup” (문자열, 동적 조회), and “no hard link” (하드 링크 없음).

The binary contains the request selector and the status selector, and ATTrackingManager appears as a string. The otool -L output showed no direct link to AppTrackingTransparency. I read this as the SDK looking up the class dynamically. From that I inferred that the app has to link the framework, but I did not reproduce what happens when the link is removed.

Four decisions that changed the order

First, the app’s ATT request moved to after UMP finishes. If the permission is already decided, the guard returns right away, so there is no duplicate request.

Second, I kept the app’s ATT call. I assumed that the SDK does not request ATT when the explainer message is not published, and in that case the app requests it instead. This reduces the dependence on whether the message is published before or after the code is merged. My judgment was that removing the app’s call would leave no way to obtain the IDFA until publishing, and AdMob ads would be served as non-personalized.

Third, UMP runs once even on the path where no consent screen is needed. I took needsForm to mean only whether a consent screen is required. The point is to give the SDK a chance to handle the explainer outside the EEA too, and I expected it to return immediately when no message is published.

This is the branch for the case where no consent screen is needed.

case .none, .formOnly:
    guard AdsConfig.adMobEnabled else { break }
    // 폼이 «필요 없어도» UMP 흐름을 한 번 돌립니다.
    // 메시지가 게시돼 있지 않으면 즉시 반환합니다(no-op).
    umpFlowRan = await consentProvider.presentConsentForm()

The two comments say that the UMP flow runs once even when no form is needed, and that it returns immediately as a no-op when no message is published.

Fourth, maybeStartAdMob() does not wait for the ATT answer. In the main market the ad chain uses AdFit, so there is nothing to gain from waiting for the first AdMob impression. I also considered that the banner can be covered while the ATT alert is up.

This part schedules ATT only when the UMP flow actually ran.

if AdsConfig.adMobEnabled, umpFlowRan {
    Task { await ATTAuthorization.request() }
}
maybeStartAdMob()

Three fixes from code review

The first was the refresh failure. .refreshFailed, which comes from a network error, fell into the .none handling in an if/else if structure. If the cache still held “no screen needed,” UMP returned success and the app could request ATT without knowing whether the explainer was published. I switched to a switch and handled the failure separately. A side benefit is that the compiler points out a missing branch when the enum gains a value later. In the excerpt below, the .none and .formOnly branch is the same as the code shown earlier, so it is shortened.

switch prompt {
case .refreshFailed:
    // 게시 여부를 모르는 상태입니다. 아무것도 하지 않습니다.
    // 동의 상태는 UMP가 마지막으로 아는 값이 유지되고, 다음 런치에 재시도됩니다.
    break
case .introThenForm:
    umpFlowRan = await presentConsentIntro()
case .none, .formOnly:
    // …
}

The comments under .refreshFailed say that the publishing state is unknown so nothing is done, that the consent state keeps the last value UMP knows, and that it is retried on the next launch.

The second was the app’s own intro sheet. When the intro sheet shown before the SDK screen is dismissed with the scrim or the grabber, presentConsentForm() is not called. umpFlowRan checks whether UMP actually ran, and on this path ATT is skipped. The intro sheet is shown again each time the app starts.

The third was the ad provider condition. A guard that looks at third-party ads as a whole passes when only another provider is enabled. ATT could run even in a configuration with AdMob turned off. The WKWebView-based provider does not use the IDFA. I narrowed the ATT and UMP conditions to AdsConfig.adMobEnabled.

What ATTAuthorization.request() does

The code that imports AppTrackingTransparency is gathered in one file.

static func request() async {
    guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return }
    if UIApplication.shared.applicationState != .active {
        await withCheckedContinuation { continuation in
            var observer: NSObjectProtocol?
            observer = NotificationCenter.default.addObserver(
                forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
            ) { _ in
                if let observer { NotificationCenter.default.removeObserver(observer) }
                continuation.resume()
            }
        }
    }
    await withCheckedContinuation { continuation in
        ATTrackingManager.requestTrackingAuthorization { _ in continuation.resume() }
    }
}

The guard at the top ends right away unless the status is .notDetermined. Even though the function is called on every launch, it does not call the request API for users who have already answered.

Next comes the check that the app is .active. Requesting directly from a startup callback such as didFinishLaunching or willConnectTo can return .notDetermined without showing a screen. Usually sceneDidBecomeActive has passed while UMP waits on the network, but the function waits for didBecomeActiveNotification itself so that it does not rely on that timing.

What I confirmed and what I could not

Based on the develop branch as of 2026-08-16 (commits 2ae29c42 through 657d87c7), iOS passed 301 tests in 60 suites with xcodebuild test, and Android succeeded at compileDebugKotlin. On first launch in the simulator, the ATT alert appeared as before, and running UMP with no published message did not add another modal. The log on the following launch showed [ATTrackingManager] Returning from trackingAuthorizationStatus - 0. The value 0 means not determined, so that path requests again.

To imitate a regulated US state, I added the -AdMobDebugUSState argument on iOS and --ez admobDebugUsState true on Android. I confirmed in the SDK header that UMPDebugGeographyRegulatedUSState has the value 3 and wired it in.

Two things are unconfirmed. simctl privacy grant tracking failed with Operation not permitted, so there was no way to inject an ATT answer automatically. Whether the app stops asking after the user has answered is left for QA on a real device. I also could not see whether the explainer shows again on the second launch after the message is published, because the publishing conditions were not met at the time.

There are remaining limits too. Until the explainer message is published, the app’s ATT alert can appear without the explainer. That is the intended fallback behavior. What I saw in the binary is an observation limited to the UMP release in use at the time, and it is not behavior that the official Google documentation guarantees. It has to be investigated again when the SDK changes.

One piece of documentation debt is left. The comments in ATTAuthorization.swift still describe the old calling rule. The statement that it runs regardless of the screen, the statement that it runs right after refreshConsent(), and the statement that ATT and UMP are independent no longer match the implementation. I corrected two comments in other files but missed this file, so I filed an issue for it. When a premise changes, an earlier decision has to be reviewed again. So a decision record should include the premises that make the conclusion hold, not only the conclusion.

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.