Skip to content

4.16.2 - #498

Open
yusuftor wants to merge 34 commits into
masterfrom
develop
Open

4.16.2#498
yusuftor wants to merge 34 commits into
masterfrom
develop

Conversation

@yusuftor

@yusuftor yusuftor commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Changes in this pull request

Promotes developmaster for the 4.16.2 patch release.

Enhancements

  • Adds the user's system-wide text size (Dynamic Type) as three device attributes for use in paywalls and audience filters: fontScale, fontSize, and preferredContentSizeCategory.
  • Links subscriptions to the user server-side after a successful purchase or restore.

Fixes

  • Fixes network requests that can never succeed, such as those with an invalid API key, taking up to a minute to fail instead of failing straight away. Timeouts and server errors still retry as before.
  • Fixes failed network requests being reported as a decoding error rather than the HTTP error that actually occurred.
  • Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls.

Release contents

Version 4.16.2 (Constants.swift, SuperwallKit.podspec, CHANGELOG.md all match)
Commits 34 since master
PRs included #488, #489, #490, #494

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

Greptile Summary

This patch prepares the 4.16.2 release.

  • Adds Dynamic Type device attributes for paywall templates and audience evaluation.
  • Introduces V2 debugger endpoints and compact paywall summaries to support accounts with many paywalls.
  • Maps non-2xx responses to HTTP errors and avoids retrying terminal responses.
  • Redeems existing subscription codes after native purchases and restores.
  • Updates release metadata, tests, and the Advanced example’s RevenueCat resolution.

Confidence Score: 4/5

The PR should not merge until Dynamic Type collection is made main-thread-safe and purchase/restore redemption is bound to the identity associated with the completed transaction.

Ordinary background device-attribute collection now reaches UIKit trait APIs without main-actor isolation, while deferred redemption can observe a different global identity after a logout or account switch.

Files Needing Attention: Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift; Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift

Important Files Changed

Filename Overview
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Adds Dynamic Type attributes, but reads UIKit trait state from device-building paths that are not main-actor isolated.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Adds post-purchase and post-restore subscription linking, but defers identity capture through fire-and-forget tasks.
Sources/SuperwallKit/Network/Custom URL Session/CustomURLSession.swift Correctly maps all non-2xx responses before decoding so callers receive the underlying HTTP failure.
Sources/SuperwallKit/Misc/Extensions/Task+Retrying.swift Returns terminal HTTP responses immediately while retaining retries for server and transient client failures.
Sources/SuperwallKit/Debug/DebugViewController.swift Reworks debugger loading around V2 resolve/list endpoints and compact picker data without an accepted defect.
Sources/SuperwallKit/Network/API.swift Adds routing for the V2 paywall debugger API.
Sources/SuperwallKit/Network/Endpoint.swift Replaces the all-paywalls debugger request with dedicated resolve and preview-list endpoints.

Sequence Diagram

sequenceDiagram
  participant App as Host App
  participant TM as TransactionManager
  participant RM as ReceiptManager
  participant Redeemer as WebEntitlementRedeemer
  participant Identity as IdentityManager
  participant API as Backend
  App->>TM: Purchase or restore succeeds
  TM->>RM: Reload receipt and entitlements
  TM-->>Redeemer: Launch unstructured redemption task
  App->>Identity: Optional identify/reset
  Redeemer->>Identity: Read current identity
  Redeemer->>API: Redeem existing codes
Loading
Prompt To Fix All With AI
### Issue 1
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:231-233
**UIKit state read off-main**

When background tasks build device attributes, `preferredContentSizeCategory` reads `UIApplication` and `UIScreen` state without main-actor isolation, causing Main Thread Checker violations and unsafe UIKit access. The related `fontSize` and `fontScale` reads have the same threading problem.

### Issue 2
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift:860-862
**Redemption captures stale identity**

When the host identifies or resets the user immediately after a successful purchase, this fire-and-forget task constructs its request using the identity current when the actor eventually executes, rather than the identity associated with the transaction. This can link the receipt to the next or anonymous user instead of the user who completed the purchase; the restore path has the same issue.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Merge pull request #489 from superwall/f..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

claude and others added 30 commits July 2, 2026 20:34
The debug/preview flow fetched ALL paywalls for the app just to translate
the deep-link numeric `paywall_id` into the paywall `identifier`, which is
slow and breaks for apps with many paywalls.

Add a single-lookup resolver: `GET /v2/paywalls/resolve?id=<id>` on the V2
API returns `{ id, identifier, name }` for a paywall id, scoped to the
app's public key. The preview flow now resolves the identifier with one
request, then fetches that one paywall exactly as before.

- Add `.paywallsV2` endpoint host (base host, `/v2/` prefix)
- Add `Endpoint.resolvePaywall(byDatabaseId:)` + `Network.resolvePaywallIdentifier`
  (authenticated with the public key via `isForDebugging: false`)
- Rewrite `DebugViewController` preview to use the resolver; drop the fetch-all
- Remove the now-unused `Paywalls` list model and `getPaywalls()`
- The "Your Paywalls" multi-paywall picker is removed (it depended on the
  fetch-all); previewing one paywall by id is unaffected

Requires the backend resolver endpoint (superwall/paywall-next#3456).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the UIContentSizeCategory-to-dashboard-token switch into a pure
static DeviceHelper.contentSizeCategoryToken(for:) and lock every token
via unit tests, since these tokens are a backend audience-filter contract.
Add system font size as a device attribute
…ew-resolve-endpoint

# Conflicts:
#	Sources/SuperwallKit/Network/API.swift
With the fetch-all gone, the "Your Paywalls" multi-paywall picker can no
longer be populated, so `pressedPreview()` was unreachable and the picker
chip still advertised a dropdown that did nothing.

- Remove `pressedPreview()` and the always-empty `paywalls` property
- Drop the picker tap target from the name chip and the preview container
- Remove the down-arrow affordance; the chip is now a display-only name
  label (renamed `previewPickerButton` -> `previewNameButton`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The V2 API is served from the `superwall.com` domain (api.superwall.com in
production, api.superwall.dev in developer), which is a DIFFERENT apex
domain than `baseHost` (the legacy v1 API on api.superwall.me). Using
baseHost would have sent the resolver to api.superwall.me/v2/... in
production and 404'd.

Add a dedicated `NetworkEnvironment.apiV2Host` (mirroring `enrichmentHost`,
another superwall.com-domain service) and point the `PaywallsV2` host
config at it. Local uses localhost:3001 (the apps/api wrangler dev port).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A successful native (App Store) purchase only refreshed entitlements
locally via `loadPurchasedProducts` and never called `/redeem`, so a new
subscription for an already-identified user wasn't linked to them
server-side until another trigger fired.

Call `webEntitlementRedeemer.redeem(.existingCodes)` after
`loadPurchasedProducts` in `TransactionManager.loadPurchasedProductsIfNeeded`
so the freshly-loaded receipts + appTransactionId are pushed to the
backend on every native purchase. This is the single funnel for both SK1
and SK2 purchases and, being gated by `shouldSkipReceiptLoading`, skips
custom/web products and test mode. Restores go through `didRestore` and
are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrsXRkhSGciN62RK7mVpBV
Extend SW-5516 (already redeeming after a native purchase) to also fire
webEntitlementRedeemer.redeem(.existingCodes) on the successful restore
path. Added in TransactionManager.didRestore, which is only reached after
a restore succeeds and after receipts/entitlements have been reloaded, so
a freshly-restored subscription is linked to the identified user
server-side. Skipped in test mode, mirroring the purchase path's
shouldSkipReceiptLoading gate. Updated the purchase-path comment and
CHANGELOG accordingly.
Trigger /redeem after a successful native purchase
`Task.retrying` threw `URLError(.badServerResponse)` for any non-2xx
response, which its own `catch` then swallowed and retried. A permanent
failure such as 401 or 404 therefore burned the full backoff schedule —
roughly 65 seconds and 7 round-trips at the default `retryCount` of 6 —
before `CustomURLSession.getRequestId` surfaced it to the caller.

Return the response immediately for terminal client errors instead. This
matches what already happens once retries are exhausted, since the loop's
final attempt returns the response unchecked, so callers see the same
error, just without the wasted requests and delay.

Client errors that may succeed on a retry (408, 425, 429, 499) and all
server errors keep their existing retry behaviour.

Fixes #492

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getRequestId` only threw for 401 and 404. Every other non-2xx response
returned normally, so the error body was then decoded as if it were a
success payload. That failed, and the caller received
`NetworkError.decoding` — describing the wrong failure — while the SDK
tracked a `network_decoding_fail` event for what was really an HTTP error.

Add `NetworkError.http(statusCode:)` and classify every non-2xx response
through `NetworkError.make(fromStatusCode:)`. 401 and 404 keep their
existing dedicated cases and log messages, so nothing matching on those
changes behaviour. The three near-identical logging blocks collapse into
one that also records the status code.

`NetworkError` gains an explicit `Equatable` conformance because adding an
associated value drops the conformance simple enums get implicitly, which
`PaywallLogic.handlePaywallError` relies on for `error == .notFound`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`NetworkError.make(fromStatusCode:)` treats any non-2xx response as an
error, but `TaskRetryLogic.isTerminal` only considered 4xx terminal. A 3xx
therefore threw `URLError(.badServerResponse)` and burned the full retry
schedule before surfacing as `.http(statusCode:)`. The test suite asserted
both opinions, so it documented the contradiction rather than catching it.

A redirect only reaches the caller when `URLSession` couldn't follow it —
a 3xx with no `Location` header, for instance — and it comes back the same
way however many times it's sent, so there's nothing to gain from
retrying. Reframe `isTerminal` as "not 2xx, and retrying can't help": 5xx
and the retryable client errors are still sent again, everything else
outside 2xx is terminal.

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

Don't retry terminal HTTP errors, and surface them properly
…endpoint

Brings back the multi-paywall picker this branch removed in c6cde4b,
without reinstating the fetch-all that made it removable.

The picker let you switch previews from inside the debugger. It was backed
by the SDK fetching every paywall in full; when that went away the array
could never be populated, `pressedPreview()` was unreachable behind a
`guard !paywalls.isEmpty`, and the chip advertised a dropdown that did
nothing — so it was deleted. Without it, previewing a different paywall
means going back to the dashboard for a fresh QR code.

paywall-next#3657 adds `GET /v2/paywalls/preview-list`, which returns
id/identifier/name for the non-archived paywalls of the application in the
debugger's `sat_` preview token — no presentable paywall JSON. That is
enough to render the picker at a fraction of the old payload, fetched on
demand rather than on every debugger launch.

- `PaywallPreviewList` / `PaywallPreviewListItem` decodables
- `Endpoint.listPreviewPaywalls` on the `.paywallsV2` host
- `Network.listPreviewPaywalls`, same `isForDebugging: true` auth as the
  resolver, with a lower retry count since it only feeds an optional picker
- Restores `previewPickerButton` (name, down arrow, tap target) and
  `pressedPreview`, now keyed off `previewPaywalls`

The list loads after the previewed paywall is on screen, so the picker
never delays what the user asked for, and a failure degrades to an empty
picker rather than an error. `pressedPreview` needs more than one entry
before opening — an action sheet offering only the paywall already on
screen is noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings on the picker restoration.

`hasMore` was a non-optional `Bool` that nothing read. The decoder only
applies `convertFromSnakeCase`, so if `preview-list` ever stopped sending
`has_more` the whole list decode would throw `keyNotFound` and the picker
would silently empty behind a `.warn`. The SDK does not paginate, so the
field is dropped rather than made optional — no reason to carry a decode
dependency on something unused.

`loadPreviewPaywalls()` sat at the end of the render success path, and both
the resolve `catch` and the fetch `catch` return before reaching it. The
picker therefore only populated when the paywall loaded, leaving the
down-arrow inert exactly when switching away is most useful — the case the
picker exists for. It now runs from `viewDidLoad` alongside the preview
load, concurrently and independently, so a failed render still offers
alternatives.

Calling it only from `viewDidLoad` also means switching paywalls via the
picker no longer refetches a list that cannot have changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fills the gap left by the picker restoration: `listPreviewPaywalls` had no
test, while `resolvePaywall` did.

- `listPreviewPaywalls_endpointBuildsRequest` mirrors the resolver's test —
  GET, correct path, and the `sat_` debug key as the bearer. Also asserts
  the URL carries no query string: the application is taken from the token's
  scope server-side, so a client-supplied `application_id` would be a way
  around that scoping.
- Three decoding tests around `PaywallPreviewList`, which declares only
  `data` while the endpoint also returns `object`, `has_more` and
  `application_id`. They pin both directions — undeclared fields present and
  absent — plus the empty-list case, so the response shape can change
  without a `keyNotFound` silently emptying the picker.

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

Two more review findings.

`pressedPreview` still bailed on `guard let id = paywallDatabaseId`, which
was the last thing keeping the picker inert in the case b708cda set out to
fix: a debug deep link without a `paywall_id` leaves it nil, so nothing
renders, but the now-independent list load populates `previewPaywalls` and
the picker refused to open anyway. The id was only used to mark the current
row with a checkmark, so it never needed to be non-nil.

Replaces both that guard and the `count > 1` check with a single condition —
open when the list contains something other than what is already on screen.
That still declines on an empty list and on a single entry matching the
current paywall, while opening when nothing rendered.

The endpoint tests matched only the path, so they passed regardless of which
host resolved — exactly how the V2 resolver shipped pointing at the v1
`baseHost` (b073dda). Both now assert host and path together.

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

`/resolve` and `/preview-list` return the same three fields (id, identifier,
name), so the two identically-shaped structs collapse into `PaywallSummary`.
`PaywallPreviewList` stays as the list envelope, and its endpoint keeps its own
`Response == PaywallPreviewList` constraint.

Also lifts these types out of Paywall.swift into their own files under
Models/Paywall, and trims their doc comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +231 to +233
let category = UIApplication.sharedApplication?.preferredContentSizeCategory
?? UIScreen.main.traitCollection.preferredContentSizeCategory
return Self.contentSizeCategoryToken(for: category)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 UIKit state read off-main

When background tasks build device attributes, preferredContentSizeCategory reads UIApplication and UIScreen state without main-actor isolation, causing Main Thread Checker violations and unsafe UIKit access. The related fontSize and fontScale reads have the same threading problem.

Knowledge Base Used: Networking Layer

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Line: 231-233

Comment:
**UIKit state read off-main**

When background tasks build device attributes, `preferredContentSizeCategory` reads `UIApplication` and `UIScreen` state without main-actor isolation, causing Main Thread Checker violations and unsafe UIKit access. The related `fontSize` and `fontScale` reads have the same threading problem.

**Knowledge Base Used:** [Networking Layer](https://app.greptile.com/superwall/-/custom-context/knowledge-base/superwall/superwall-ios/-/docs/networking-layer.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The two new font device attributes resolve against an ambient trait collection that Apple documents as undefined in the context they're read from, so they can disagree with the preferredContentSizeCategory shipped alongside them. Worth settling before this goes out, since these feed audience filters.

Reviewed changes — full review of the 4.16.2 release promotion (developmaster), covering all 34 commits across the four aggregated PRs (#488, #489, #490, #494).

  • Dynamic Type device attributesDeviceHelper gains fontSize, fontScale and preferredContentSizeCategory, wired into DeviceTemplate and getTemplateDevice(), with a contentSizeCategoryToken(for:) map that is a backend audience-filter contract.
  • Terminal HTTP errors no longer retriedTaskRetryLogic.isTerminal(statusCode:) short-circuits the backoff for 3xx and non-{408,425,429,499} 4xx, so an invalid API key fails immediately instead of after the full retry schedule.
  • Non-2xx surfaced as the real errorNetworkError becomes Equatable, gains .http(statusCode:), make(fromStatusCode:) and logMessage; CustomURLSession throws before decoding so failures stop masquerading as .decoding / network_decoding_fail.
  • Redeem after native purchase and restoreTransactionManager fires webEntitlementRedeemer.redeem(.existingCodes) from loadPurchasedProductsIfNeeded and didRestore, both excluded in test mode.
  • Debugger preview moved to the V2 API — new paywallsV2 endpoint host plus paywalls/resolve and paywalls/preview-list, replacing the fetch-every-paywall approach; Paywalls/getPaywalls() deleted and the "Your Paywalls" picker restored on a PaywallSummary model.
  • SWBounceButton title fixshowLoading = false on an already-idle button no longer blanks the title.
  • Release hygiene — 4.16.2 in Constants.swift, SuperwallKit.podspec and CHANGELOG.md; project regenerated; new tests for retry classification, NetworkError, the V2 endpoints and the content-size token map.

I traced the retry/error-classification change through every endpoint and every NetworkError consumer and found no regression: only PaywallLogic.swift:63 matches a specific case (.notFound, unchanged), and redemption error states are body-level discriminators inside 2xx responses, so no endpoint loses a body it still needs to decode. The two redeem triggers also check out — both are reachable only on success, and the purchase and restore paths are disjoint, so a single user action can't fire both.

ℹ️ The new redeem triggers have no test coverage and are wired through the singleton rather than the injected factory

Both new redeem(.existingCodes) calls reach through Superwall.shared.dependencyContainer.webEntitlementRedeemer — the only two places in TransactionManager that bypass its own injected factory, where ConfigManager and IdentityManager hold the redeemer as a constructor-injected dependency. That's also why neither call site can be exercised in a test, and there is currently no test anywhere that covers linking a subscription server-side after a purchase or restore. CLAUDE.md asks for a unit test with new functionality, and this one ships as a CHANGELOG-advertised enhancement.

Technical details
# Redeem-on-purchase/restore: DI shortcut and missing coverage

## Affected sites
- `Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift:490``didRestore` reaches
  `Superwall.shared.dependencyContainer.webEntitlementRedeemer` directly.
- `Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift:861``loadPurchasedProductsIfNeeded` does the same.
- `Tests/SuperwallKitTests/` — no `TransactionManagerTests.swift` exists; neither trigger is
  covered by any test.

## Required outcome
- The redeemer is reachable from `TransactionManager` through the same dependency-injection route
  its other collaborators use, so the trigger can be observed in a test.
- At least one test asserts that a successful purchase and a successful restore each fire
  `redeem(.existingCodes)` exactly once, and that test mode fires neither.

## Suggested approach
- Add a `makeWebEntitlementRedeemer()` (or equivalent) requirement to the factory protocol
  composition `TransactionManager` already depends on (`FactoryProtocols.swift:88`,
  `TransactionManagerFactory`), matching how `ConfigManager.swift:44` and `IdentityManager.swift:120`
  hold `private unowned let webEntitlementRedeemer: WebEntitlementRedeemer`.
- In `didRestore`, the test-mode guard currently reads as a bare `return` at the end of the
  function, which silently swallows anything appended after it later. Inverting it to
  `if !factory.makeTestModeManager().isTestMode { Task { ... } }` keeps the same behaviour without
  that trap.

ℹ️ Nitpicks

  • Sources/SuperwallKit/Debug/DebugViewController.swift:121-125 — the doc comment says previewPaywalls is "Empty when the request fails or the app has a single paywall", but a single-paywall app returns a one-entry list, not an empty one. The pressedPreview comment on line 339 describes that case correctly; this one contradicts it.
  • Sources/SuperwallKit/Debug/DebugViewController.swift:227-235 — when resolvePaywallIdentifier fails (deleted or wrong paywall_id in a deep link) finishLoadingPreview returns without activityIndicator.stopAnimating(), leaving the debugger spinning with no user-facing error. The removed getPaywalls() failure path had the same gap, so this isn't a regression, but the new resolve endpoint makes it easier to hit.
  • Sources/SuperwallKit/Debug/DebugViewController.swift:344 — tapping the picker in the window between viewDidLoad and loadPreviewPaywalls() completing is a silent no-op, since previewPaywalls is still empty. Indistinguishable from "the request failed" for the user.
  • Sources/SuperwallKit/Models/Paywall/PaywallSummary.swift:14id is typed String and the only evidence for that shape is the PR's own fixtures. Worth confirming against the live apps/api worker: a numeric id degrades gracefully for preview-list (empty picker) but breaks every paywall_id deep link through resolvePaywallIdentifier.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +210 to +225
var fontSize: Int {
#if os(visionOS)
return 16
#else
return Int(UIFontMetrics.default.scaledValue(for: 16.0).rounded())
#endif
}

var fontScale: Double {
#if os(visionOS)
return 1.0
#else
let scale = UIFontMetrics.default.scaledValue(for: 16.0) / 16.0
return (scale * 100).rounded() / 100
#endif
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scaledValue(for:) is the implicit-trait overload — it resolves against UITraitCollection.current, which Apple documents as undefined outside UIView/UIViewController/UIPresentationController trait callbacks and stores as a thread-local. getTemplateDevice() and the public Superwall.getDeviceAttributes() are plain async with no main-actor isolation, so these two can report an unscaled 16 / 1.0 while preferredContentSizeCategory right below — read from UIApplication, which is not trait-context dependent — correctly reports e.g. xxxLarge. Deriving all three from one explicit trait collection makes them agree and removes the thread dependency.

Technical details
# Font attributes resolve against an undefined ambient trait collection

## Affected sites
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:214``fontSize` via
  `UIFontMetrics.default.scaledValue(for: 16.0)`.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:222``fontScale` via the same call.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:231``preferredContentSizeCategory`
  reads a different, trait-independent source (`UIApplication.sharedApplication?.preferredContentSizeCategory`),
  which is why the three can disagree.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:656-658` — all three feed
  `DeviceTemplate` in `getTemplateDevice()`, which is `async` and not main-actor isolated.

## Required outcome
- `fontSize`, `fontScale` and `preferredContentSizeCategory` all derive from a single content-size
  category, so they can never contradict each other in the payload sent to audience filters.
- The reported values reflect the user's actual Dynamic Type setting regardless of which thread or
  executor `getTemplateDevice()` happens to run on.
- The values stay live: the user can change text size mid-session, so caching once at `init` (the
  way `makeScreenMetrics()` caches screen bounds) would be wrong here.

## Suggested approach
- Resolve the category once (the existing `UIApplication.sharedApplication?.preferredContentSizeCategory`
  `??` `UIScreen.main.traitCollection.preferredContentSizeCategory` expression), then pass
  `UITraitCollection(preferredContentSizeCategory:)` into
  `UIFontMetrics.default.scaledValue(for:compatibleWith:)` for both numbers.
- If the `UIApplication` read itself should be main-thread-safe, the file already has the house
  pattern for it in `makeScreenMetrics()` (line ~141):
  `Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read)`.

## Open questions for the human
- Are `fontSize` and `fontScale` both needed on the backend? `fontSize` is `fontScale * 16` rounded,
  so one is derivable from the other and two fields are two chances to disagree.

## Sources
- `UITraitCollection.current`: "Outside these methods, you're responsible for ensuring the `current`
  property has a valid trait collection. Otherwise, the contents of the collection are undefined."
  and "UIKit stores the value of the `current` property as a thread-local variable." —
  https://developer.apple.com/documentation/uikit/uitraitcollection/current
- `scaledValue(for:)` — "Scales an arbitrary layout value based on the current Dynamic Type settings"
  vs. `scaledValue(for:compatibleWith:)` — "...and the specified traits." —
  https://developer.apple.com/documentation/uikit/uifontmetrics/scaledvalue(for:compatiblewith:)

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.

3 participants