<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ninomaeDev</title>
    <description>The latest articles on DEV Community by ninomaeDev (@ninomaedev).</description>
    <link>https://dev.to/ninomaedev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4079753%2F8eca6aa6-6d12-4611-86b0-2abdf1c88c48.png</url>
      <title>DEV Community: ninomaeDev</title>
      <link>https://dev.to/ninomaedev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ninomaedev"/>
    <language>en</language>
    <item>
      <title>Why your App Tracking Transparency prompt doesn't show up (and how it got my app rejected)</title>
      <dc:creator>ninomaeDev</dc:creator>
      <pubDate>Sun, 16 Aug 2026 06:12:52 +0000</pubDate>
      <link>https://dev.to/ninomaedev/why-your-app-tracking-transparency-prompt-doesnt-show-up-and-how-it-got-my-app-rejected-oob</link>
      <guid>https://dev.to/ninomaedev/why-your-app-tracking-transparency-prompt-doesnt-show-up-and-how-it-got-my-app-rejected-oob</guid>
      <description>&lt;p&gt;App Review rejected my iOS app under Guideline 2.1. The note said reviewers were unable to locate the App Tracking Transparency permission request when they tested the build.&lt;/p&gt;

&lt;p&gt;The prompt worked on my iPhone. Every single launch. It just didn't work on theirs.&lt;/p&gt;

&lt;p&gt;The cause turned out to be two properties of the ATT API that are easy to miss individually and genuinely nasty in combination: together they produce a bug that is invisible on a fast device and completely reproducible on a slow one. Your test device is fast. The reviewer's device is not necessarily.&lt;/p&gt;

&lt;p&gt;This post is the root cause, the fix I shipped, and the list of other things that silently suppress the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two facts that explain everything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. iOS only presents the ATT prompt while your app is active
&lt;/h3&gt;

&lt;p&gt;Apple's documentation for &lt;code&gt;requestTrackingAuthorization(completionHandler:)&lt;/code&gt; states, for iOS 15 and later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Calls to the API only prompt when the application state is UIApplicationStateActive."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's &lt;code&gt;UIApplication.State.active&lt;/code&gt; — not merely "in the foreground," and not "the code is running." During launch there is a window where your JS/UI is already executing but the app is still &lt;code&gt;inactive&lt;/code&gt;: splash screen dismissal, the first render, a modal transition animating in or out. Call the API in that window and iOS declines to present.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. When iOS declines to present, you don't get an error
&lt;/h3&gt;

&lt;p&gt;You get &lt;code&gt;notDetermined&lt;/code&gt; back (&lt;code&gt;undetermined&lt;/code&gt; in &lt;code&gt;expo-tracking-transparency&lt;/code&gt;) — which is the &lt;em&gt;exact same value&lt;/em&gt; you get when the user simply hasn't answered yet.&lt;/p&gt;

&lt;p&gt;There is no "I couldn't show it" signal. There is no thrown error. There is no &lt;code&gt;presented: false&lt;/code&gt; flag. From the return value alone, &lt;strong&gt;"the user hasn't decided yet" and "iOS silently no-op'd your request" are indistinguishable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the trap. The API looks like it succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I shipped
&lt;/h2&gt;

&lt;p&gt;Reduced to its essentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Called during startup, while the splash screen was still going away.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;requestTrackingPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;nonPersonalizedOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// and that's it — never asked again this process&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two mistakes, stacked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Requested too early.&lt;/strong&gt; The call fired before the app reached &lt;code&gt;active&lt;/code&gt;, so iOS sometimes skipped the prompt entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treated &lt;code&gt;undetermined&lt;/code&gt; as a final answer.&lt;/strong&gt; Anything that wasn't &lt;code&gt;granted&lt;/code&gt; got folded into "not granted," cached for the process lifetime, and never retried.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Individually, either one is survivable. Together they mean: if the first attempt slips, that install &lt;em&gt;never&lt;/em&gt; sees the prompt again — not on that launch, and on subsequent launches the same race can repeat.&lt;/p&gt;

&lt;p&gt;On my phone, launch was fast enough that the app was usually &lt;code&gt;active&lt;/code&gt; by the time the call landed. On the review device it wasn't. That timing difference is the entire distance between "ships" and "rejected."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The tell:&lt;/strong&gt; if &lt;code&gt;status&lt;/code&gt; comes back &lt;code&gt;undetermined&lt;/code&gt; &lt;em&gt;after&lt;/em&gt; you explicitly requested authorization, that is not a user declining. That is iOS never asking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix, part 1: wait for &lt;code&gt;active&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Don't request on a timer, and don't request "after 2 seconds" and hope. Observe the actual app state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ACTIVE_WAIT_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitUntilActive&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;settled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;settled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// Never leave this pending forever — ATT must not block the rest of startup.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ACTIVE_WAIT_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Catch the case where we transitioned to active between the check above&lt;/span&gt;
    &lt;span class="c1"&gt;// and the listener being attached.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The re-check after subscribing.&lt;/strong&gt; There is a real gap between reading &lt;code&gt;AppState.currentState&lt;/code&gt; and the listener being registered. If the transition happens inside that gap, you wait for an event that already fired. This is the kind of race that shows up once a week in production and never on your desk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timeout.&lt;/strong&gt; A permission helper that can hang forever will eventually hang forever, and it will take your ad SDK init (or worse, your splash screen) with it. Resolve on timeout and let the caller carry on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix, part 2: retry on &lt;code&gt;undetermined&lt;/code&gt; instead of giving up
&lt;/h2&gt;

&lt;p&gt;Since "not presented" and "not answered" look identical, the only way to tell them apart is to try again and see if anything changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;getTrackingPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isAvailable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;isTrackingApiAvailable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;requestTrackingPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-tracking-transparency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** Spacing between presentation attempts. Length = max attempts. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ATT_ATTEMPT_DELAYS_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureTrackingConsent&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Android / iOS &amp;lt; 14: the library reports the granted-equivalent. Nothing to ask.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isTrackingApiAvailable&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Already answered (this install, or a previous launch)? Then iOS will never&lt;/span&gt;
  &lt;span class="c1"&gt;// show the dialog again, and requesting is pointless.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTrackingPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undetermined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settleMs&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ATT_ATTEMPT_DELAYS_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitUntilActive&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settleMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// We may have dropped back to inactive while waiting. Requesting now would&lt;/span&gt;
    &lt;span class="c1"&gt;// just burn an attempt with nothing shown.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;requestTrackingPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undetermined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Prompt appeared and the user answered.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Still undetermined → it was never presented. Back off and try again.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why an increasing delay rather than a fixed one: the situations that suppress the prompt (transition animations, another permission dialog on screen, the system settling after launch) all resolve on their own, but on timescales you can't predict. Short first attempt so a healthy launch prompts quickly; longer later attempts so a slow device still gets there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't hammer it.&lt;/strong&gt; Requesting permissions in a tight loop is its own failure mode, and other permission dialogs (notifications, location) put your app into &lt;code&gt;inactive&lt;/code&gt; while they're on screen — so a burst of retries during a notification prompt is five guaranteed misses.&lt;/p&gt;

&lt;p&gt;Also worth noting: this whole helper should run &lt;strong&gt;once per process&lt;/strong&gt; and hand the same promise to every caller. Two concurrent ATT requests do not queue up; the second one is simply lost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;trackingConsent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTrackingConsent&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;trackingConsent&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;ensureTrackingConsent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;trackingConsent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And don't &lt;code&gt;await&lt;/code&gt; it before showing ads. Start ads in non-personalized mode, and let the consent result flip the flag when it arrives. A retry loop that gates your first ad request is a retry loop that costs you your first ad impression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part 3: give users a manual trigger
&lt;/h2&gt;

&lt;p&gt;There will still be environments where the automatic request slips. So I added a row in Settings — "Ad tracking settings" — that calls &lt;code&gt;requestTrackingPermissionsAsync()&lt;/code&gt; directly on tap.&lt;/p&gt;

&lt;p&gt;This is worth doing for two reasons beyond the user-facing one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It gives App Review a deterministic path to the prompt that doesn't depend on launch timing, and you can describe it in the review notes.&lt;/li&gt;
&lt;li&gt;Once the user &lt;em&gt;has&lt;/em&gt; answered, iOS won't show the dialog again — so this row should detect that state and deep-link to &lt;code&gt;Settings.app&lt;/code&gt; instead of silently doing nothing. &lt;code&gt;Linking.openSettings()&lt;/code&gt; handles that on iOS.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTrackingStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undetermined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;requestTrackingPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSettings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// already answered — only the OS can change it now&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The same two rules in native Swift
&lt;/h2&gt;

&lt;p&gt;I ship the React Native version above, so treat this as the principle translated rather than production code I've run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;AppTrackingTransparency&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;UIKit&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;TrackingRequester&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSObjectProtocol&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;requestWhenActive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;ATTrackingManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trackingAuthorizationStatus&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notDetermined&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;applicationState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NotificationCenter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;forName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;didBecomeActiveNotification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;object&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;observer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;NotificationCenter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestWhenActive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;ATTrackingManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requestTrackingAuthorization&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="c1"&gt;// status == .notDetermined here means the prompt was never presented.&lt;/span&gt;
            &lt;span class="c1"&gt;// Schedule another attempt rather than recording this as a denial.&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two rules: request only while &lt;code&gt;.active&lt;/code&gt;, and never record &lt;code&gt;.notDetermined&lt;/code&gt; as a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other reasons the prompt won't appear
&lt;/h2&gt;

&lt;p&gt;Before you go rewrite your state handling, rule these out — several of them will make a &lt;em&gt;correct&lt;/em&gt; implementation look broken:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The user already answered.&lt;/strong&gt; iOS remembers the choice for the lifetime of the install. Per Expo's docs, it won't prompt again unless the app is deleted and reinstalled. This is the number one reason "my fix didn't work" — you already tapped a button on that device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System-wide tracking requests are off.&lt;/strong&gt; Settings → Privacy &amp;amp; Security → Tracking → "Allow Apps to Request to Track" disabled means no app gets a prompt, ever. Your API call returns denied/undetermined with nothing shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Another permission dialog is pending.&lt;/strong&gt; Apple documents that the prompt won't display while another permission request is awaiting the user. If you fire notifications + location + ATT at launch, they don't queue politely. Sequence them — request one, and only request the next from the previous one's completion handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're calling from an app extension.&lt;/strong&gt; Apple documents that calls through an app extension don't prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NSUserTrackingUsageDescription&lt;/code&gt; is missing from your Info.plist.&lt;/strong&gt; No string, no prompt. In Expo this goes in &lt;code&gt;app.json&lt;/code&gt; under &lt;code&gt;ios.infoPlist&lt;/code&gt; (or via the config plugin for &lt;code&gt;expo-tracking-transparency&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS 13 or earlier / non-iOS.&lt;/strong&gt; No ATT framework. Guard with an availability check so this path doesn't look like a failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulator.&lt;/strong&gt; Behavior differs from device. Verify on hardware before concluding anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resetting so you can actually test the fix
&lt;/h2&gt;

&lt;p&gt;Because the answer is sticky per install, testing takes discipline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delete and reinstall the app.&lt;/strong&gt; This is the reliable reset for the per-app status.&lt;/li&gt;
&lt;li&gt;Toggling "Allow Apps to Request to Track" off and on in Settings also affects behavior, and is the fastest way to reproduce the "nothing shows up" state on purpose.&lt;/li&gt;
&lt;li&gt;To reproduce the &lt;em&gt;original&lt;/em&gt; bug rather than the fix, artificially delay reaching &lt;code&gt;active&lt;/code&gt; — put the request behind a heavy synchronous startup path, or test on the oldest supported device you have. Fast hardware hides this defect.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I sent App Review
&lt;/h2&gt;

&lt;p&gt;Code changes alone weren't what got it through — evidence was.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A screen recording on a real device&lt;/strong&gt;, from cold launch to the prompt appearing, unedited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review notes with explicit steps&lt;/strong&gt;: launch the app, wait on the home screen, prompt appears; alternatively, Settings → Ad tracking settings → tap.&lt;/li&gt;
&lt;li&gt;A one-line explanation of what changed since the previous submission.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It passed on the third submission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest caveat:&lt;/strong&gt; Apple documents the &lt;code&gt;active&lt;/code&gt;-state requirement, but Apple does &lt;em&gt;not&lt;/em&gt; document "retry on &lt;code&gt;notDetermined&lt;/code&gt;" as the sanctioned remedy. That part is what fixed it in my case, on my code path. Treat it as a field report, not as a spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the other rejection, if you ship subscriptions
&lt;/h2&gt;

&lt;p&gt;Since it's the same trip through App Review and it's another pure "did you know" problem — my first rejection was Guideline 3.1.2: auto-renewable subscriptions offered without a functional link to the Terms of Use (EULA) in the app's metadata.&lt;/p&gt;

&lt;p&gt;I had the links in the in-app paywall. What was missing was the &lt;strong&gt;store-side metadata&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reliable fix: put the EULA link &lt;strong&gt;directly in your App Description&lt;/strong&gt;, regardless of whether you use the standard EULA or a custom one. App Store Connect's App Information screen has a License Agreement section, but if you select the standard EULA there is no URL field there to fill in — so the description text is where a reviewer can actually click it.&lt;/p&gt;

&lt;p&gt;Apple's subscriptions page states plainly that your app &lt;em&gt;and&lt;/em&gt; your App Store metadata must include links to your Terms of Use and Privacy Policy, with no conditional attached. Standard EULA URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metadata-only change, no rebuild required. Ten minutes once you know; two days if you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: what this was for
&lt;/h2&gt;

&lt;p&gt;The app is &lt;strong&gt;QuesToDo&lt;/strong&gt;, an offline-first RPG-flavored todo app for iPhone — finish a task, earn EXP, level up a pixel-art character. Built solo over 23 days with an AI agent (Claude Code) doing implementation while I owned design decisions and on-device verification. Expo SDK 54 / React Native 0.81 / TypeScript strict, &lt;code&gt;expo-sqlite&lt;/code&gt; with additive migrations v1 → v9, 366 tests across 25 suites, 14,451 lines of implementation code. All data stays on device; there is no backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No numbers to report on the other side of the launch&lt;/strong&gt; — it shipped on 2026-08-07 and I have no downloads, users, or revenue worth writing about. This post is about the review process, not about traction.&lt;/p&gt;

&lt;p&gt;If you want to look at it: &lt;a href="https://apps.apple.com/app/questodo/id6794613228" rel="noopener noreferrer"&gt;QuesToDo on the App Store&lt;/a&gt; (free, with in-app purchases).&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;iOS only presents the ATT prompt when your app is in the &lt;code&gt;active&lt;/code&gt; state. Wait for it — observe &lt;code&gt;AppState&lt;/code&gt; / &lt;code&gt;didBecomeActiveNotification&lt;/code&gt;, don't guess with a timer.&lt;/li&gt;
&lt;li&gt;When iOS declines to present, the API returns &lt;code&gt;notDetermined&lt;/code&gt; with no error. &lt;strong&gt;&lt;code&gt;notDetermined&lt;/code&gt; after a request is not a denial.&lt;/strong&gt; Retry it with backoff.&lt;/li&gt;
&lt;li&gt;Request once per process, don't stack it against other permission dialogs, and don't block your ad SDK on the result.&lt;/li&gt;
&lt;li&gt;Add a manual trigger in Settings and film it for App Review.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  日本語まとめ (Japanese summary)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ATTダイアログが「審査環境でだけ」出ない問題の原因と対処&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;原因は2つの事実の組み合わせです。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;iOSのATTダイアログは、アプリが &lt;code&gt;UIApplicationStateActive&lt;/code&gt; のときしか表示されません。&lt;/strong&gt; 起動直後（スプラッシュ解除中・初回レンダー中・モーダルの遷移中）はまだ &lt;code&gt;inactive&lt;/code&gt; のことがあり、そこで要求しても提示されません。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;提示が見送られたとき、APIはエラーを返しません。&lt;/strong&gt; ダイアログを出さないまま &lt;code&gt;notDetermined&lt;/code&gt;（Expoでは &lt;code&gt;undetermined&lt;/code&gt;）を返します。これは「ユーザーがまだ答えていない」ときと同じ値なので、&lt;strong&gt;戻り値だけでは「出せなかった」と「まだ答えていない」を区別できません。&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;自分のコードは起動直後に要求し、返ってきた &lt;code&gt;undetermined&lt;/code&gt; を「拒否された」と解釈してプロセス内で二度と要求しない設計でした。手元のiPhoneは起動が速くたまたま &lt;code&gt;active&lt;/code&gt; に間に合っていた、それだけの差で審査では出ませんでした。&lt;/p&gt;

&lt;p&gt;対策は3点セットです。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;(1)&lt;/strong&gt; &lt;code&gt;AppState&lt;/code&gt; を購読して &lt;code&gt;active&lt;/code&gt; になるまで待ってから要求する（購読直前に遷移した取りこぼしを拾う再チェックと、永久pendingを防ぐタイムアウトを入れる）&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(2)&lt;/strong&gt; &lt;code&gt;undetermined&lt;/code&gt; は「拒否」ではなく「提示失敗」として、間隔を空けて再試行する（600ms / 1.5s / 3s / 5s / 8s）。他の権限ダイアログ表示中はアプリが &lt;code&gt;inactive&lt;/code&gt; になるため、権限要求を連打しない&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(3)&lt;/strong&gt; 設定画面に手動要求の導線を置く。回答済みの端末ではiOSがダイアログを出さないので、その場合は &lt;code&gt;Linking.openSettings()&lt;/code&gt; で設定アプリへ送る&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ダイアログが出ない原因は他にもあります。&lt;strong&gt;回答済み&lt;/strong&gt;（削除＋再インストールでのみリセット）、&lt;strong&gt;「Appからのトラッキング要求を許可」がオフ&lt;/strong&gt;、&lt;strong&gt;他の権限ダイアログが処理待ち&lt;/strong&gt;、&lt;strong&gt;App Extensionからの呼び出し&lt;/strong&gt;、&lt;strong&gt;&lt;code&gt;NSUserTrackingUsageDescription&lt;/code&gt; の未設定&lt;/strong&gt;。修正前に必ず切り分けてください。&lt;/p&gt;

&lt;p&gt;審査には&lt;strong&gt;実機の画面収録&lt;/strong&gt;（起動→ダイアログ表示）と、操作手順を書いたレビューノートを添付しました。3回目で承認されました。なお「&lt;code&gt;notDetermined&lt;/code&gt; を再試行すべき」とAppleが明文化しているわけではなく、これは自分の環境でこう直したら通った、という体験ベースの話です。&lt;/p&gt;

&lt;p&gt;おまけとして、サブスクを提供する場合は&lt;strong&gt;アプリ説明文（App Description）にEULAのリンクを直接書く&lt;/strong&gt;必要があります（Guideline 3.1.2）。アプリ内のペイウォールにリンクがあるだけでは足りず、ストア側のメタデータにも要ります。標準EULAを選ぶとApp Store ConnectにURL入力欄がないため、説明文に書くのが確実です。この修正は&lt;strong&gt;メタデータのみ・再ビルド不要&lt;/strong&gt;でした。&lt;/p&gt;

&lt;p&gt;ATTに加えてSQLiteの追記型マイグレーションと時刻の引数注入まで含めた日本語の記事はQiitaにあります: &lt;a href="https://qiita.com/ninomaeDev/items/d56129c46c227d09f6cc" rel="noopener noreferrer"&gt;https://qiita.com/ninomaeDev/items/d56129c46c227d09f6cc&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
