Skip to content

feat: ad-supported free tier with Ad-Free subscription - #11

Merged
weskcode merged 6 commits into
mainfrom
feature/ads-and-iap
Sep 6, 2026
Merged

weskcode merged 6 commits into
mainfrom
feature/ads-and-iap

Conversation

@weskcode

@weskcode weskcode commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Summary

Ad-supported free tier with a cross-device Ad-Free subscription.

  • Ad-Free subscription (StoreKit 2): org.wesley.sunhat.adfree.monthly ($1/mo) + .yearly ($10/yr) as the same level (crossgrade); StoreManager derives entitlement state from Transaction.currentEntitlements + status API with live update listeners; grace period stays ad-free; unknown entitlement fails closed.
  • Paywall: AdFreePaywallView over Apple's SubscriptionStoreView; Settings gains a "SunHat Ad-Free" section (plan display, manage/fix billing, Restore Purchases).
  • AdMob banners (Google test IDs): SDK startup gated on affirmative non-entitlement + UMP consent, ATT deferred past first session (AdActivationPolicy, unit-tested); compact 320×50 banner slots on Dashboard and Weather only, minimal presentation (no chrome, a11y-labeled "Advertisement").
  • Localization: string catalog normalized; Spanish translations completed for all shipped strings; obsolete "Remove Ads" key replaced by the banner label.

Testing

  • Unit: full suite green (ad gates, entitlement resolver, activation policy, catalog completeness)
  • UI: banner slot test retargeted to the ad-banner accessibility element; purchase flow test runs with -sunhatDisableAdSDK
  • Known pre-existing flake: StoreManagerStoreKitTests purchase outcome can be .cancelled on the Xcode-27-beta simulator (StoreKit-Testing storefront wedge; documented in the test file)

Go-live

Real AdMob IDs + App Store Connect products are deliberately NOT in this PR — every value and location is listed in the internal go-live doc.

weskcode and others added 6 commits August 31, 2026 19:47
Introduces SunHat's monetization: banner ads for free users, removable by an
auto-renewable "SunHat Ad-Free" subscription. Everything runs on test/sandbox
values (Google test ad units, a local StoreKit configuration), so the full
loop is verifiable before any live IDs exist.

StoreKit 2 (Services/Store/)
- SunHat.storekit: one subscription group, monthly $1 and annual $10 at the
  same level so switching plans is a crossgrade. Product IDs are final and can
  be created verbatim in App Store Connect.
- StoreManager: @observable, shared plus injected via .environment(). Derives
  entitlement from Transaction.currentEntitlements refined by subscription
  status, and listens to both Transaction.updates and Status.updates - the
  latter is what actually delivers grace-period entry, billing retry, and
  cancellation, none of which create a transaction. A generation counter keeps
  overlapping refreshes from committing stale results.
- AdFreeEntitlement: StoreKit-free state machine (unknown/notEntitled/active/
  gracePeriod/billingRetry). Grace period keeps ads off so a billing hiccup
  never punishes a paying user; unknown fails closed so a subscriber never
  sees an ad flash while StoreKit resolves.

Paywall and Settings
- AdFreePaywallView: a custom marketing header over SubscriptionStoreView, so
  iOS renders the tier picker, localized pricing, auto-renewal disclosure, and
  restore. Monthly is listed first and pre-selected; the annual tier carries
  its better-value framing in its own description rather than a pre-checked
  upsell.
- Settings gains a "SunHat Ad-Free" section: current plan and renewal date,
  manage/cancel via Apple's sheet, a fix-billing path, and Restore Purchases.

Ads (Services/Ads/, Views/Ads/)
- Google Mobile Ads 12.14.0 via SPM, the project's first third-party
  dependency, with UMP for consent.
- AdManager starts the SDK only on affirmative non-entitlement, gathers UMP
  consent concurrently (never blocking activation on a network call), and
  defers App Tracking Transparency past the first session. AdActivationPolicy
  holds that timing logic as pure, unit-tested code.
- AdBannerSlot reserves fixed space, separates the ad from app content and
  from its own "Remove Ads" link, and renders nothing at all for subscribers.
  Placed on Dashboard and Weather only - not onboarding, not the paywall.

Privacy
- Info.plist: test GADApplicationIdentifier, SKAdNetworkItems, and an ATT
  usage string. PrivacyInfo.xcprivacy now declares NSPrivacyTracking.
- The in-app privacy policy discloses AdMob, tracking, and the subscription;
  it previously stated the opposite.

Tests: 326 passing, including SKTestSession coverage of purchase, restore,
expiry, and entitlement mirroring, plus UI coverage of the ad slot.
…to iOS 26.5

Follow-up to 9b75c93, from a multi-agent audit of the committed monetization
work. Twenty findings were confirmed by adversarial verification; the two
critical ones were a real Google EU User Consent Policy violation and a test
that was masking a store misconfiguration.

Consent (Google EU User Consent Policy)
- Banner slots could go live the instant the SDK started, before UMP had
  answered, so an EEA user's first ad request could precede consent.
  consentBlocksAds now starts from UMP's persisted verdict - readable
  synchronously, no network - so the gate fails closed and only ever opens on
  an affirmative "ads may be requested".
- A thrown consent update previously left the gate wherever it happened to be,
  serving a whole session with no consent record. It now falls back to the
  stored verdict.
- privacyOptionsRequired was a computed passthrough to a Google SDK property
  carrying no Observation instrumentation, so the row Google requires could
  simply never appear. It is now a stored, observed value refreshed after every
  consent operation and when Settings appears.

StoreKit ordering
- Entitlement resolution refines its answer with subscription-group status,
  which needs a loaded product - but currentEntitlements is served from cache
  while Product.products is a network round trip, so the first resolution of a
  launch ran without status and grace-period and billing-retry were unreachable
  until the next foreground. A first successful catalog load now re-resolves.
- restorePurchases() read shared state that a concurrent listener refresh could
  overtake, so a successful restore could report "No Purchases Found". It now
  returns the state its own run resolved, and the alert uses that.
- The paywall sheet was hosted inside the entitlement branch that completing a
  purchase flips, tearing down its own presenter mid-flow.

Tests
- The scheme's TestAction was missing its StoreKitConfigurationFileReference
  (set only on LaunchAction), so UI-test launches got no local storefront. This
  was previously misdiagnosed as an iOS 27 beta bug and papered over with an
  XCTSkip; the config is now attached and the test fails loudly instead.
- Dropped an assertion that a subscriber sees no ads from a launch that
  disables the ad SDK, where it could never fail. AdManagerGateTests now covers
  the real gate through AdManager's injection seam.

Platform and disclosure
- Deployment target raised to iOS 26.5 across all three targets.
- 29 monetization strings added to the String Catalog with Spanish, plus the
  ATT prompt in InfoPlist.xcstrings; the whole surface was English-only.
- README and CONTRIBUTING still advertised zero tracking and zero third-party
  dependencies, which the shipped binary contradicts.
- PrivacyInfo.xcprivacy claimed Google's SDKs declare the tracking domains.
  Verified against the shipped xcframeworks: they declare neither
  NSPrivacyTracking nor NSPrivacyTrackingDomains, so this app's manifest is the
  only tracking declaration and its empty domain list is a go-live blocker.
… storefront attach

Two fixes to AdFreeIntegrationUITests, both found by actually running it:

- The buy button was looked up as buttons["Subscribe"], which matches on
  IDENTIFIER. SubscriptionStoreView carries the renewal disclosure in that
  button's LABEL ("Subscribe / Plan auto-renews for $1.00/month until
  canceled."), so the lookup never matched even when the paywall had fully
  loaded. Now matched by label prefix.
- The simulator's StoreKit-Testing storefront is not always attached on a
  run's first app launch ("Subscription Unavailable"); a relaunch picks it up.
  Retry once for that transient, then fail loudly. Still never skips.

Also corrects the file header, which repeated the earlier misdiagnosis of the
storefront problem as an iOS 27 beta bug; the real cause was the scheme's
TestAction missing its StoreKit configuration, fixed in 87580fc.
…en the purchase test fails

- `UMPDebugGeography.notEEA` is deprecated in UserMessagingPlatform 3.x; use
  `.other`, which is the documented replacement. This is DEBUG-only code (it
  makes simulator runs behave like a non-EEA user, because Google's sample app
  ID has no published consent message), so it does not affect Release
  behavior — but the project keeps a zero-warning bar.
- `testPurchaseUnlocksAdFreeAndPersists` asserted on "Current Plan" appearing
  with no diagnostic. When that assertion failed the screen state was
  invisible, which cost real debugging time; it now captures a screenshot
  first, like the other failure paths in this test.

Verified on Xcode 26.6 / iOS 26 SDK: all 6 StoreManagerStoreKitTests pass
(0.66s for the purchase path), and Release builds for a generic iOS device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lations

- Apply Xcode-style formatting to Localizable.xcstrings (pending reformat
  from the working tree)
- Add the 12 Spanish translations shipped without one: recovery-mode
  notices, privacy-policy copy, preferred delivery time, quiet hours, and
  the Advertising section — LocalizableCatalogTests is green again
- Drop the obsolete "Remove Ads" key and add "Advertisement" for the
  banner advertisement accessibility label
- Replace the full-width anchored adaptive banner (50-90pt tall, plus its
  width-probe and clipping machinery) with Google's smallest standard unit,
  the fixed 320x50 banner, centered on its own reserved 50pt row
- Drop the divider, "Remove Ads" row, and paywall sheet from the bottom
  slot; the paywall stays reachable in Settings' "SunHat Ad-Free" section
- Presentation follows QuoteReaper's AdBannerView: transparent banner,
  explicit root view controller, static "Advertisement" accessibility
  element
- Keep everything safety-relevant: entitlement + consent gating, reserved
  height with no layout shift, fade-in, blank-on-fail
- AdFreeIntegrationUITests now waits for the banner's "ad-banner"
  accessibility element instead of the removed "Remove Ads" button
@weskcode
weskcode merged commit 8cf5ce8 into main Sep 6, 2026
1 check passed
@weskcode
weskcode deleted the feature/ads-and-iap branch September 6, 2026 00:28
weskcode added a commit that referenced this pull request Sep 6, 2026
* feat: add ad-supported free tier with Ad-Free subscription

Introduces SunHat's monetization: banner ads for free users, removable by an
auto-renewable "SunHat Ad-Free" subscription. Everything runs on test/sandbox
values (Google test ad units, a local StoreKit configuration), so the full
loop is verifiable before any live IDs exist.

StoreKit 2 (Services/Store/)
- SunHat.storekit: one subscription group, monthly $1 and annual $10 at the
  same level so switching plans is a crossgrade. Product IDs are final and can
  be created verbatim in App Store Connect.
- StoreManager: @observable, shared plus injected via .environment(). Derives
  entitlement from Transaction.currentEntitlements refined by subscription
  status, and listens to both Transaction.updates and Status.updates - the
  latter is what actually delivers grace-period entry, billing retry, and
  cancellation, none of which create a transaction. A generation counter keeps
  overlapping refreshes from committing stale results.
- AdFreeEntitlement: StoreKit-free state machine (unknown/notEntitled/active/
  gracePeriod/billingRetry). Grace period keeps ads off so a billing hiccup
  never punishes a paying user; unknown fails closed so a subscriber never
  sees an ad flash while StoreKit resolves.

Paywall and Settings
- AdFreePaywallView: a custom marketing header over SubscriptionStoreView, so
  iOS renders the tier picker, localized pricing, auto-renewal disclosure, and
  restore. Monthly is listed first and pre-selected; the annual tier carries
  its better-value framing in its own description rather than a pre-checked
  upsell.
- Settings gains a "SunHat Ad-Free" section: current plan and renewal date,
  manage/cancel via Apple's sheet, a fix-billing path, and Restore Purchases.

Ads (Services/Ads/, Views/Ads/)
- Google Mobile Ads 12.14.0 via SPM, the project's first third-party
  dependency, with UMP for consent.
- AdManager starts the SDK only on affirmative non-entitlement, gathers UMP
  consent concurrently (never blocking activation on a network call), and
  defers App Tracking Transparency past the first session. AdActivationPolicy
  holds that timing logic as pure, unit-tested code.
- AdBannerSlot reserves fixed space, separates the ad from app content and
  from its own "Remove Ads" link, and renders nothing at all for subscribers.
  Placed on Dashboard and Weather only - not onboarding, not the paywall.

Privacy
- Info.plist: test GADApplicationIdentifier, SKAdNetworkItems, and an ATT
  usage string. PrivacyInfo.xcprivacy now declares NSPrivacyTracking.
- The in-app privacy policy discloses AdMob, tracking, and the subscription;
  it previously stated the opposite.

Tests: 326 passing, including SKTestSession coverage of purchase, restore,
expiry, and entitlement mirroring, plus UI coverage of the ad slot.

* fix: close EEA consent gap, restore ordering races, and raise target to iOS 26.5

Follow-up to 9b75c93, from a multi-agent audit of the committed monetization
work. Twenty findings were confirmed by adversarial verification; the two
critical ones were a real Google EU User Consent Policy violation and a test
that was masking a store misconfiguration.

Consent (Google EU User Consent Policy)
- Banner slots could go live the instant the SDK started, before UMP had
  answered, so an EEA user's first ad request could precede consent.
  consentBlocksAds now starts from UMP's persisted verdict - readable
  synchronously, no network - so the gate fails closed and only ever opens on
  an affirmative "ads may be requested".
- A thrown consent update previously left the gate wherever it happened to be,
  serving a whole session with no consent record. It now falls back to the
  stored verdict.
- privacyOptionsRequired was a computed passthrough to a Google SDK property
  carrying no Observation instrumentation, so the row Google requires could
  simply never appear. It is now a stored, observed value refreshed after every
  consent operation and when Settings appears.

StoreKit ordering
- Entitlement resolution refines its answer with subscription-group status,
  which needs a loaded product - but currentEntitlements is served from cache
  while Product.products is a network round trip, so the first resolution of a
  launch ran without status and grace-period and billing-retry were unreachable
  until the next foreground. A first successful catalog load now re-resolves.
- restorePurchases() read shared state that a concurrent listener refresh could
  overtake, so a successful restore could report "No Purchases Found". It now
  returns the state its own run resolved, and the alert uses that.
- The paywall sheet was hosted inside the entitlement branch that completing a
  purchase flips, tearing down its own presenter mid-flow.

Tests
- The scheme's TestAction was missing its StoreKitConfigurationFileReference
  (set only on LaunchAction), so UI-test launches got no local storefront. This
  was previously misdiagnosed as an iOS 27 beta bug and papered over with an
  XCTSkip; the config is now attached and the test fails loudly instead.
- Dropped an assertion that a subscriber sees no ads from a launch that
  disables the ad SDK, where it could never fail. AdManagerGateTests now covers
  the real gate through AdManager's injection seam.

Platform and disclosure
- Deployment target raised to iOS 26.5 across all three targets.
- 29 monetization strings added to the String Catalog with Spanish, plus the
  ATT prompt in InfoPlist.xcstrings; the whole surface was English-only.
- README and CONTRIBUTING still advertised zero tracking and zero third-party
  dependencies, which the shipped binary contradicts.
- PrivacyInfo.xcprivacy claimed Google's SDKs declare the tracking domains.
  Verified against the shipped xcframeworks: they declare neither
  NSPrivacyTracking nor NSPrivacyTrackingDomains, so this app's manifest is the
  only tracking declaration and its empty domain list is a go-live blocker.

* test: make the purchase UI test match the real buy button and survive storefront attach

Two fixes to AdFreeIntegrationUITests, both found by actually running it:

- The buy button was looked up as buttons["Subscribe"], which matches on
  IDENTIFIER. SubscriptionStoreView carries the renewal disclosure in that
  button's LABEL ("Subscribe / Plan auto-renews for $1.00/month until
  canceled."), so the lookup never matched even when the paywall had fully
  loaded. Now matched by label prefix.
- The simulator's StoreKit-Testing storefront is not always attached on a
  run's first app launch ("Subscription Unavailable"); a relaunch picks it up.
  Retry once for that transient, then fail loudly. Still never skips.

Also corrects the file header, which repeated the earlier misdiagnosis of the
storefront problem as an iOS 27 beta bug; the real cause was the scheme's
TestAction missing its StoreKit configuration, fixed in 87580fc.

* fix: use UMP's non-deprecated debug geography, and show the screen when the purchase test fails

- `UMPDebugGeography.notEEA` is deprecated in UserMessagingPlatform 3.x; use
  `.other`, which is the documented replacement. This is DEBUG-only code (it
  makes simulator runs behave like a non-EEA user, because Google's sample app
  ID has no published consent message), so it does not affect Release
  behavior — but the project keeps a zero-warning bar.
- `testPurchaseUnlocksAdFreeAndPersists` asserted on "Current Plan" appearing
  with no diagnostic. When that assertion failed the screen state was
  invisible, which cost real debugging time; it now captures a screenshot
  first, like the other failure paths in this test.

Verified on Xcode 26.6 / iOS 26 SDK: all 6 StoreManagerStoreKitTests pass
(0.66s for the purchase path), and Release builds for a generic iOS device.

* chore: normalize string catalog formatting and complete Spanish translations

- Apply Xcode-style formatting to Localizable.xcstrings (pending reformat
  from the working tree)
- Add the 12 Spanish translations shipped without one: recovery-mode
  notices, privacy-policy copy, preferred delivery time, quiet hours, and
  the Advertising section — LocalizableCatalogTests is green again
- Drop the obsolete "Remove Ads" key and add "Advertisement" for the
  banner advertisement accessibility label

* fix: slim ad banner slots to the compact 320x50 unit

- Replace the full-width anchored adaptive banner (50-90pt tall, plus its
  width-probe and clipping machinery) with Google's smallest standard unit,
  the fixed 320x50 banner, centered on its own reserved 50pt row
- Drop the divider, "Remove Ads" row, and paywall sheet from the bottom
  slot; the paywall stays reachable in Settings' "SunHat Ad-Free" section
- Presentation follows QuoteReaper's AdBannerView: transparent banner,
  explicit root view controller, static "Advertisement" accessibility
  element
- Keep everything safety-relevant: entitlement + consent gating, reserved
  height with no layout shift, fade-in, blank-on-fail
- AdFreeIntegrationUITests now waits for the banner's "ad-banner"
  accessibility element instead of the removed "Remove Ads" button
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant