feat: ad-supported free tier with Ad-Free subscription - #11
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ad-supported free tier with a cross-device Ad-Free subscription.
org.wesley.sunhat.adfree.monthly($1/mo) +.yearly($10/yr) as the same level (crossgrade);StoreManagerderives entitlement state fromTransaction.currentEntitlements+ status API with live update listeners; grace period stays ad-free; unknown entitlement fails closed.AdFreePaywallViewover Apple'sSubscriptionStoreView; Settings gains a "SunHat Ad-Free" section (plan display, manage/fix billing, Restore Purchases).AdActivationPolicy, unit-tested); compact 320×50 banner slots on Dashboard and Weather only, minimal presentation (no chrome, a11y-labeled "Advertisement").Testing
ad-banneraccessibility element; purchase flow test runs with-sunhatDisableAdSDKStoreManagerStoreKitTestspurchase outcome can be.cancelledon 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.