Skip to content

Actor refactor: PaywallViewCache, TestMode and Entitlements - #466

Merged
ianrumac merged 10 commits into
developfrom
ir/refactor/actor-cache-test-mode
Oct 2, 2026
Merged

ianrumac merged 10 commits into
developfrom
ir/refactor/actor-cache-test-mode

Conversation

@ianrumac

Copy link
Copy Markdown
Collaborator

Changes in this pull request

Continues the actor migration after identity (#387) and config (#400). Two commits:

Add actors for PV cache and TestMode

  • PaywallViewCache state (view map + active key) now lives in a SequentialActor via PaywallCacheState/PaywallCacheContext; loading and shimmer views are acquired atomically across concurrent callers.
  • TestMode becomes an actor: TestModeState gains pure reducers and async actions (product refresh, activation/modal), with TestModeContext and a pure TestModeLogic for evaluation rules. ConfigState calls TestMode.activate directly instead of the removed activateTestMode callback.
  • ConfigState moves from config.models to config.
  • Rebased onto current develop: keeps loadingColor, awaitTestProducts/productsLoaded, session preservation on re-activation, and the ensureActive guard in PaywallMessageHandler. The cache view factories no longer hop to Dispatchers.Main (callers block with runBlocking from the main thread, so that deadlocked), and TestMode.activate is now suspend with ConfigState launching it in its own scope.

Move Entitlements onto the StateActor primitives

  • EntitlementsState holds status, product, device, backing and web entitlements as an immutable snapshot with pure reducers; createInitialEntitlementsState rebuilds it from storage before the actor starts.
  • Entitlements is a facade over the actor; its public/internal API is unchanged so callers are untouched.
  • Web entitlements are cached in state; WebPaywallRedeemer publishes them through a new Factory.setWebEntitlements hook wherever it writes the redemption response.
  • Adds EntitlementsRefactorSafetyTest (46 cases) and reworks EntitlementsTest.

Not ported from the old draft (#383): SdkState, which depended on that draft's SdkContext. #383 can be closed once this lands.

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • 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 ktlint 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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Dropping the eager LoadingView/ShimmerView seeding from PaywallViewCache.init breaks the unchecked retrieveView(LoadingView.TAG) as LoadingView cast in SuperwallPaywallActivity, which is reachable from the shipped getPaywall() + startWithView() flow. See the inline comment on the init block.

Reviewed changes — full read of the 34-file diff at a9d47c4, plus the actor primitives in misc/primitives/, every ViewStorage consumer, and the reset/identify paths that invalidate web entitlements.

  • PaywallViewCache moved onto SequentialActor — view map and active key live in PaywallCacheState; save/removePaywallView/removeAll became suspend (propagated to PaywallManager and its tests); ViewStorage demoted from source of truth to a write-through mirror hydrated once at construction.
  • Loading/shimmer views now lazy — created inside acquireLoadingView()/acquireShimmerView() behind a runBlocking bridge instead of eagerly in the constructor.
  • TestMode became an actor — immutable TestModeSessionData, Updates reducers, RefreshProducts/Activate actions, pure TestModeLogic.evaluate, and an injected showModal lambda.
  • ConfigContext.activateTestMode removed — ConfigState calls TestMode.activate directly; ConfigState also moved from config.models to config, which accounts for most of the import churn.
  • Entitlements became a facade over EntitlementsState — pure reducers, createInitialEntitlementsState(storage) replay before the actor starts, explicit persist(...) in place of the status-flow collector.
  • web entitlements changed from a live storage read to cached state — pushed in through the new WebPaywallRedeemer.Factory.setWebEntitlements hook at three write sites.
  • Tests — new PaywallViewCacheTest (30 cases), EntitlementsRefactorSafetyTest (46 cases), reworked EntitlementsTest, two new TestModeTest modal-flow cases, and verify → coVerify migrations.

I confirmed parity for the parts most likely to regress silently: all/active/inactive/byProductId are faithful ports (including the pre-existing quirk where AddProductEntitlements overwrites rather than merges allTracked), inactive == all - active held before and after because the old _inactive was always empty, and Superwall.reset(duringIdentify = true) runs storage.reset() before reedemer.clear(...) so the cached web set is invalidated on reset and identity switch.

⚠️ ViewStorage is now a dual source of truth with unsynchronised external writers

The class doc calls ViewStorage a write-through mirror for "external readers (SuperwallPaywallActivity, DebugView)", but both of those also write to it directly, bypassing the actor: SuperwallPaywallActivity.kt:162 (removeView(key) on launch failure), :192 and :289 (storeView), and DebugView.kt:928. Because every cache read now comes from state.value instead of ViewStorage, those writes are invisible to the cache — where the old implementation read through and picked them up.

Technical details
# Cache state and ViewStorage can diverge under external writes

## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt:164-171` — `ViewStorage` is read exactly once, at construction, via `Updates.Hydrate`.
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt:173-187` — `entries`, `activePaywallVcKey`, `activePaywallView`, `getAllPaywallViews`, `getPaywallView` all read `state.value`.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:159-164` — on `launchPaywallActivity` failure, `makeViewStore().removeView(key)` evicts from `ViewStorage` only.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:192`, `:289` — `storeView(key, …)` writes to `ViewStorage` only.
- `superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt:928` — `storeView(key, view)` writes to `ViewStorage` only.

## Concrete divergence
After a failed activity launch, `ViewStorage` no longer holds `key` but `PaywallCacheState.views` still does. The next
`PaywallManager.getPaywallView(...)` (`PaywallManager.kt:79`) gets a cache **hit** on the evicted view instead of falling
through to `factory.makePaywallView(...)`. Pre-PR, `getPaywallView` read `store.retrieveView(key)` and correctly returned
`null`, forcing a fresh view.

`PaywallCacheState.Actions.RemoveAllExceptActive` (`PaywallViewCache.kt:90-96`) also narrowed from
"every key in `ViewStorage`" to "every key the actor knows about", so `DebugView`'s entries are no longer swept by
`resetCache()`.

## Required outcome
Cache reads and `ViewStorage` must not be able to disagree about which keys exist. Either route the external writers
through the cache actor, or make the reads authoritative on `ViewStorage` again for the keys those writers own.

## Open questions for the human
- Is `ViewStorage` meant to remain a long-term second source of truth, or is the intent to migrate
  `SuperwallPaywallActivity`/`DebugView` onto the cache actor in a follow-up? The answer changes whether this is a
  bug to fix now or a documented interim state.
- `RemoveAllExceptActive` no longer sweeping foreign keys looks like an improvement rather than a regression —
  confirm that's intentional.

ℹ️ Behaviour this PR changes that no added test exercises

Two gaps stand out, both on paths that only fail at runtime on a device:

  • The invariant that acquireLoadingView()/acquireShimmerView() must have run before SuperwallPaywallActivity.prepareViewForDisplay reads the tags is now load-bearing, and nothing asserts it. PaywallViewCacheTest has no case that reads LoadingView.TAG from ViewStorage before an acquire* call.
  • The new setWebEntitlements plumbing is stubbed as a no-op in WebPaywallRedeemerTest.kt:153, and there is no DependencyContainer test, so nothing in this PR proves the WebPaywallRedeemer → DependencyContainer → Entitlements chain is wired correctly. The EntitlementsTest case that previously covered a user switch by mutating LatestRedemptionResponse in storage (old lines 750-766) was rewritten to call entitlements.setWebEntitlements(...) directly, so it now tests the setter rather than the scenario.

The PR checklist also still has UI tests, demo build and ktlint unchecked — worth completing before merge given the first finding is a device-only crash.

ℹ️ Nitpicks

  • PaywallViewCacheTest.kt:418 — assertTrue(views.size in 0..ids.size) passes for essentially any outcome, including a cache that always returns an empty list. It only proves "does not throw".
  • PaywallViewCacheTest.kt:393 — assertTrue(final!!.startsWith("k_")) would accept a corrupted concatenation like "k_3k_17"; assertTrue(final in (0 until 50).map { "k_$it" }) pins it.
  • EntitlementsRefactorSafetyTest.kt:654-659 — And("it should not contain active entitlements") filters inactive by activeIds before asserting, so the leak it claims to catch is filtered away. assertTrue(inactive.none { it.id in activeIds }) is the assertion the name promises.
  • EntitlementsRefactorSafetyTest.kt:279 — the test name says "becomes Inactive" but the body asserts status is Active (correctly, per the implementation). Renaming it avoids a future "fix" silently inverting the contract.
  • EntitlementsRefactorSafetyTest.kt:1160 and :1193 — async(Dispatchers.Default) { delay(1.seconds) }.await() adds two real seconds of wall time to wait for a collector, but persist(StoredSubscriptionStatus, …) is now a synchronous call inside setSubscriptionStatus, so the wait is no longer needed.
  • EntitlementsRefactorSafetyTest.kt / EntitlementsTest.kt — the StateActor<…>(createInitialEntitlementsState(storage), scope).let { actor -> Entitlements(storage, actor, actorScope) } block is repeated in nearly every test. A single makeEntitlements(storage, scope) helper would remove several hundred lines and make the actual test bodies legible.
  • TestModeTest.kt:1215 — expectedStatus is computed by calling manager.buildSubscriptionStatus(), the same production function whose output is under test, so the Active payload is never pinned. The sibling no-activity test hard-codes SubscriptionStatus.Inactive and reads much stronger.
  • TemplateLogic.kt — the added blank line at the top of object TemplateLogic is unrelated to this PR.
  • TestModeModalResult was widened from internal to public to support the injected showModal lambda, which pulls EntitlementSelection and FreeTrialOverride into the supported public surface.

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

Comment thread superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt Outdated
Comment thread superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues — all three prior findings are properly addressed. One import-ordering nit inline.

Reviewed changes — the delta since the prior pullfrog review at a9d47c4, i.e. 7cf3375 "Address review on actor refactor" (plus the coverage-badge commit 7d1d11d). I re-read the full 38-file diff, re-traced the getPaywall() + startWithView() path end to end, grepped every ViewStorage writer in src/main, and checked the actor/runBlocking bridge for main-thread hazards.

  • Routed external ViewStorage writers through the cache — PaywallManager.cache is now internal, and SuperwallPaywallActivity (:161, :192, :290) and DebugView.kt:927 call cache.removeView / cache.storeView, which write both the mirror and the actor state. Grep confirms no direct ViewStorage writes remain in src/main.
  • Fixed the startWithView cold-start crash — prepareViewForDisplay acquires through cache.acquireLoadingView() / cache.acquireShimmerView() instead of the unchecked retrieveView(TAG) as LoadingView casts.
  • Tightened RemoveAllExceptActive — it now evicts exactly the key set it snapshotted (Updates.RemoveViews(evicted)), so a view stored concurrently under a new key survives in both stores.
  • Wired the cache actor to the container scope — actor became a required constructor parameter and DependencyContainer.makeCache() passes SequentialActor(PaywallCacheState(), ioScope), matching Entitlements and TestMode.
  • Moved the polling setWebEntitlements inside the persist guard — memory can no longer hold web entitlements that storage does not.
  • Strengthened the tests — PaywallViewCacheTest gained five cases for the external-writer and lazy-acquire paths and replaced its two loose assertions with exact ones; EntitlementsRefactorSafetyTest fixed the self-defeating inactive filter, renamed the contradicting test, and dropped two real-time delay(1.seconds) waits; TestModeTest pins the granted entitlement set instead of deriving it from the code under test; WebPaywallRedeemerTest now asserts the published web entitlements and drops a stray println; the repeated Entitlements construction moved into EntitlementsTestHelpers.makeEntitlements.

I checked the new main-thread runBlocking bridge specifically: no action on the cache actor's FIFO ever hops to Dispatchers.Main, and DependencyContainer.ioScope is never cancelled nor SequentialActor.close() ever called, so acquireLoadingView() from startWithViewOnMain cannot deadlock on any path I could find.

ℹ️ ViewStorage stays a parallel surface that nothing structurally protects

The write side is now clean, but reads still go straight to ViewStorage (SuperwallPaywallActivity.kt:246/:257/:848, DebugView.kt:961-964) and makeViewStore() still hands out a fully mutable ViewStorage to any caller. The new invariant in the class doc — "Every write must go through this cache … so the two never disagree about which keys exist" — is therefore enforced only by a comment, and a future storeView/removeView added outside PaywallViewCache would silently reintroduce the divergence this commit just fixed. Not a bug today; a scope question worth settling before this lands.

Technical details
# The cache/ViewStorage write invariant is documented, not enforced

## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt:145-155` — class doc states the invariant.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorage.kt` — public interface whose `storeView` / `removeView` default methods mutate the shared `ConcurrentHashMap`.
- `superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt:1119-1120` — `makeViewStore()` returns that mutable `ViewStorageViewModel` to any caller.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:246`, `:257`, `:848` and `superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt:961-964` — remaining direct readers.

## Required outcome
Either it is structurally impossible to write `ViewStorage` outside `PaywallViewCache`, or the dual track is an explicitly documented interim state with a follow-up owner.

## Open questions for the human
- Is `ViewStorage` on a path to deletion once the remaining readers migrate onto the cache actor, or is it a permanent Activity-facing surface?
- If permanent: is narrowing `makeViewStore()` to a read-only view (leaving the mutable type internal to `PaywallViewCache`) worth doing now, while the call sites are fresh?

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues — both new commits are correct and well-targeted. One test-naming nit inline and one scope question below.

Reviewed changes — the delta since the prior pullfrog review at 7cf3375: 1b59a44e "Route activity view access through PaywallViewRegistry" and 690086f8 "Keep status entitlements in all after a cold start". I re-read the full 40-file diff, re-traced all seven former makeViewStore() call sites, and enumerated the stored-state combinations for createInitialEntitlementsState against the pre-PR Entitlements.init visible in the diff's removed lines.

  • Added PaywallViewRegistry and routed Activity/debug access through it — the new internal interface plus PaywallViewCache.asRegistry() is reached via DependencyContainer.makeViewRegistry(); SuperwallPaywallActivity (:161, :170, :247, :290, :848) and DebugView (:927, :965) no longer touch makeViewStore() or PaywallManager.cache, which went back to private. This settles the "write invariant is documented, not enforced" question from the last review for every Activity-facing call site.
  • Fixed a cold-start regression in createInitialEntitlementsState — AddProductEntitlements now replays before the stored status. Previously SetActive ran first and the product replay overwrote allTracked, so a status-only entitlement disappeared from all while still appearing in active — an active ⊄ all violation on every cold start with both a cached status and cached product entitlements.
  • Added OffMainViewConstructionTest — instrumented proof that LoadingView/ShimmerView construct on a Looper-less thread and still measure, draw and animate once handed to main. This closes the standing question about the actor-thread view construction introduced in 81724b5.
  • Added two unit tests — a PaywallViewCacheTest case asserting a registry storeView/removeView round-trip lands in both the cache state and ViewStorage, and an EntitlementsRefactorSafetyTest case pinning all to exactly {status_only, product_only} on a cold start. Both fail against the code they replaced.

I verified the registry indirection is behaviour-preserving: every cache write path writes viewStorage before actor.update with no suspension point between, so the registry's retrieveView (which reads viewStorage) is never behind the cache state. activityProvider and paywallManager are both assigned in the DependencyContainer constructor before any Activity can reach the container, so makeViewRegistry() forcing factory.makeCache() introduces no new throw path at the two unguarded call sites. I also enumerated stored status × product map × redemption response × corrupted-read combinations for the entitlements reorder and found the post-reorder state equivalent to pre-PR everywhere.

ℹ️ The allTracked overwrite is fixed only for the cold-start replay

The reorder makes the init path correct, but AddProductEntitlements itself still assigns allTracked = newProducts.values.flatten().toSet() rather than unioning. The same drop therefore recurs at runtime the first time addEntitlementsByProductId fires after a status update — the normal boot order once the store finishes loading products. That is exact parity with pre-PR addEntitlementsByProductId (_all.clear(); _all.addAll(...)), so it is not a regression and nothing here blocks the merge. It is worth confirming the pre-PR behaviour was deliberate, given this PR had to work around it at init.

Technical details
# `AddProductEntitlements` replaces `allTracked` instead of merging it

## Affected sites
- `superwall/src/main/java/com/superwall/sdk/store/EntitlementsState.kt:114` — `allTracked = newProducts.values.flatten().toSet()` discards anything `SetActive` previously tracked.
- `superwall/src/main/java/com/superwall/sdk/store/EntitlementsState.kt:162-168` — the new comment documents the overwrite and orders around it for the init path only.
- `superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt:146-149` — `addEntitlementsByProductId` is the runtime caller; it fires after config/product load, i.e. after the restored status is already in state.

## Behaviour
With a cached `Active(setOf(status_only))` and products that grant only `product_only`, `all` is `{status_only, product_only}` immediately after construction (correct, thanks to `690086f8`) and becomes `{product_only}` once `addEntitlementsByProductId` runs, while `active` still reports `status_only`. Pre-PR code did the same thing, so this is a parity question, not a regression.

## Open questions for the human
- Was the pre-PR `_all.clear()` in `addEntitlementsByProductId` intentional, or an old bug that the actor refactor faithfully carried over?
- If unintentional, is changing the reducer to `allTracked = state.allTracked + newProducts.values.flatten()` in scope for this PR, or a follow-up?

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues — cc00a10 is test-only and correct. The one remaining open item is the earlier OffMainViewConstructionTest naming nit, which is still unaddressed.

Reviewed changes — the delta since the prior pullfrog review at 690086f, i.e. cc00a10 "Minor fixes".

  • Fixed the PaywallViewCacheTest import ordering — org.junit.After now sorts ahead of the org.junit.Assert.* block. The prior thread has been resolved.
  • Added an end-to-end web-entitlements wiring test — WebPaywallRedeemerTest now builds a real Entitlements via makeEntitlements, wires TestFactory.setWebEntitlementsFn to entitlements.setWebEntitlements, restores user A's redemption from storage, then runs storage.clean() + redeemer.clear(AppUser) in the same order as Superwall.reset. This fills the gap raised in the first review, where the setWebEntitlements hook was stubbed out as a no-op and the user-switch scenario only tested the setter.

I checked that the new test can actually fail. After storage.clean(), clear() takes the latestResponse == null branch (WebPaywallRedeemer.kt:483). If that factory.setWebEntitlements(emptySet()) call were missing, entitlements.web would keep its cached {userA_web} and the first Then assertion would fail. The active.none { … } check matters too, because EntitlementsState.active includes webEntitlements (EntitlementsState.kt:32). The test still goes through TestFactory, not DependencyContainer.setWebEntitlements, but that override is a one-line forward (DependencyContainer.kt:1268-1269).

Still open from the prior review: the OffMainViewConstructionTest.kt:78 thread. cacheAcquireFromMainBuildsViewsOnTheActorThread never checks which thread built the views.

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No blocking issues. ce0f663 fixes the runtime allTracked drop that the last review raised. Two small follow-ups are inline.

Reviewed changes — everything since the prior pullfrog review at cc00a10: ce0f663 "Merge product entitlements into allTracked instead of replacing" and the develop merge 4aa8e17. The range-diff shows the merge only touched the coverage badges.

  • Changed AddProductEntitlements from overwrite to union. A status-only entitlement now stays in all after addEntitlementsByProductId runs, so active ⊆ all holds at runtime as well as on cold start. This is a deliberate change from the pre-PR _all.clear() behaviour.
  • Added a runtime regression test. addEntitlementsByProductId keeps status-only entitlements in all restores Active({status_only}), then adds a product mapping, and pins all to exactly {status_only, product_only}. I traced it against the old reducer: all would have been {product_only} there, so the test really can fail.
  • Renamed the off-main cache test. It is now cacheAcquireFromMainReturnsUsableViewsWithoutDeadlocking, which matches what it asserts. I replied to and resolved the prior thread.

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

Comment thread superwall/src/main/java/com/superwall/sdk/store/EntitlementsState.kt Outdated
Comment thread superwall/src/test/java/com/superwall/sdk/store/EntitlementsRefactorSafetyTest.kt Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No blocking issues. 620e459 fixes a real from == to regression and closes both prior threads. One test-assertion nit is inline.

Reviewed changes: the delta since the prior pullfrog review at 4aa8e17, which is just 620e459 "Fix delegate status 'from', keep allTracked status-only, pin delegate override". Five files changed. The other regions of the full diff are unchanged since earlier reviews.

  • Fixed the from argument passed to subscriptionStatusDidChange. This PR moved status persistence into a synchronous Entitlements.persist, so by the time the collector ran, storage.read(StoredSubscriptionStatus) already returned the new status. The collector now pairs values with scan, the same idiom the _customerInfo collector below it uses. filter { it.first != null } drops the initial emission exactly as drop(1) did.
  • Stopped AddProductEntitlements writing to allTracked. allTracked now holds only status entitlements. all still picks up product entitlements through entitlementsByProduct.values.flatten(), so a remapped product key no longer leaves its old entitlement in all/inactive. The new drops entitlements of a remapped product from all test would fail against the ce0f663 reducer. The old test was renamed to accumulates entitlements across adds. Both prior threads have been replied to and resolved.
  • Added SubscriptionStatusDelegateOverrideTest. It pins the integrator pattern of calling setSubscriptionStatus from inside subscriptionStatusDidChange, using the real addListeners collector, real Entitlements and a real SuperwallDelegateAdapter. The ordered calls assertions really can fail: with the old storage read, the relaxed storage returns null for every read, so every from would be Unknown, and the second pair (for example setOf("pro") to setOf("pro", "custom")) would not match.
  • Added an EntitlementsRefactorSafetyTest case for a status collector re-setting the status. It covers the same re-entrant pattern at the Entitlements level.
  • Fixed the save stub in PaywallManagerExperimentIsolationTest. It changed from every to coEvery, which matches PaywallViewCache.save now being suspend.

I did not run Gradle in this sandbox. Everything above was checked by reading the code.

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

ianrumac and others added 9 commits October 1, 2026 17:14
Migrates PaywallViewCache and TestMode onto the StateActor primitives:
PaywallCacheState/PaywallCacheContext own the view map and active key,
TestModeState gains pure reducers and async actions with TestModeContext
and TestModeLogic, and ConfigState moves up to the config package.

Rebased onto develop (Sep 2026) with these adaptations:
- Keep develop's loadingColor on PaywallViewCache/LoadingView.
- Port develop's productsLoaded/awaitTestProducts so StoreManager can wait
  for the test product catalog; the deferred is carried through session copies.
- SetActive preserves an existing session and only clears entitlement
  selections when the activation reason changes, matching develop.
- TestMode.activate is suspend and awaited via `immediate`; ConfigState
  launches it in its scope so the modal never blocks config.
- Cache view factories no longer hop to Dispatchers.Main: acquire* block the
  caller (usually main) with runBlocking, so that hop deadlocked.
- Keep develop's ensureActive guard in PaywallMessageHandler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports the entitlements slice of the March actor draft (ir/refactor/actors)
onto current develop:

- EntitlementsState holds status, product entitlements, device, backing and
  web entitlements as an immutable snapshot with pure reducers; `all`,
  `active` and `inactive` are derived from it. createInitialEntitlementsState
  rebuilds the snapshot from storage before the actor starts, including
  web entitlements from the latest redemption response.
- Entitlements becomes a facade over a StateActor implementing
  EntitlementsContext. Status changes and product entitlement updates are
  persisted immediately instead of through a collected flow.
- Web entitlements are cached in state rather than re-read from storage on
  every access, so WebPaywallRedeemer now publishes them through a new
  Factory.setWebEntitlements hook at each point it writes the redemption
  response.
- Adds EntitlementsRefactorSafetyTest (46 cases) and reworks EntitlementsTest
  for the actor construction.

Differences from the draft: the constructor keeps `Entitlements(storage)`
working via defaults, and EntitlementsContext no longer carries
HasExternalPurchaseControllerFactory since no action used it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Acquire loading/shimmer views through the cache in
  SuperwallPaywallActivity so getPaywall() + startWithView() no longer
  crashes when they have not been created yet.
- Route external ViewStorage writes (activity launch/restore, DebugView)
  through new synchronous PaywallViewCache.storeView/removeView so cache
  state and ViewStorage cannot diverge; RemoveAllExceptActive now removes
  exactly the keys it evicted.
- Run the cache actor on the container ioScope instead of a leaked scope.
- Only publish polled web entitlements when they are persisted.
- Tests: cover external writers and lazy loading/shimmer, assert the
  redeemer publishes what it persists, tighten weak assertions, drop
  obsolete delays, and extract a makeEntitlements helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SuperwallPaywallActivity and DebugView now reach paywall views through an
internal PaywallViewRegistry obtained from
DependencyContainer.makeViewRegistry(), instead of reaching into
paywallManager.cache or ViewStorage directly. Writes go through the cache
so it and ViewStorage stay in sync; reads use ViewStorage, which survives
Activity recreation. PaywallManager's cache is private again.

Adds an on-device test proving the loading and shimmer views can be built
on a thread without a Looper, as the cache actor does, and still draw and
animate on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createInitialEntitlementsState replayed the saved status before the stored
product entitlements. AddProductEntitlements replaces allTracked, so
status entitlements not tied to a product dropped out of `all` while
still in `active`, until the next status update. The old startup code
never replaced that set. Restore product entitlements first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AddProductEntitlements overwrote allTracked, so a status-only entitlement
dropped out of `all` once product entitlements loaded while still showing
in `active`. Also rename the off-main cache acquire test to match what it
asserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… override

- Take the delegate's previous status from the status flow. Entitlements
  now persists before the listener runs, so reading storage gave from == to.
- AddProductEntitlements no longer writes allTracked; `all` already unions
  product entitlements, and this avoids stale entries on a remapped product.
- Add SubscriptionStatusDelegateOverrideTest for setting the status from
  inside subscriptionStatusDidChange (add, remove, replace, grant).
- Stub the suspend PaywallViewCache.save with coEvery in
  PaywallManagerExperimentIsolationTest so unit tests compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ianrumac
ianrumac force-pushed the ir/refactor/actor-cache-test-mode branch from b9bd997 to 4933348 Compare October 2, 2026 09:55
@pullfrog

pullfrog Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Pullfrog billing is temporarily unavailable.

payment processing — retry shortly

Usually transient; the next dispatch should succeed. If it persists, check status.pullfrog.com or your console.

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | 𝕏

@pullfrog

pullfrog Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Pullfrog billing is temporarily unavailable.

payment processing — retry shortly

Usually transient; the next dispatch should succeed. If it persists, check status.pullfrog.com or your console.

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | 𝕏

@ianrumac
ianrumac merged commit 6b0aaf8 into develop Oct 2, 2026
1 check failed
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