Skip to content

KM-17274: purchase intents - #383

Draft
kp-mario-nachbaur wants to merge 20 commits into
masterfrom
KM-17274-purchase-intents
Draft

KM-17274: purchase intents#383
kp-mario-nachbaur wants to merge 20 commits into
masterfrom
KM-17274-purchase-intents

Conversation

@kp-mario-nachbaur

Copy link
Copy Markdown
Contributor

Start the purchasing process from the App Store via "deep link" or PurchaseIntents.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kp-mario-nachbaur's task in 7m 0s —— View job


Code review: purchase intents

  • Gather context (read diff + related files)
  • Analyze PurchaseIntentObserver (concurrency, memory, lifecycle)
  • Analyze GetStartedViewController changes
  • Analyze Client.swift / AccountProvider changes
  • Assess test coverage & impacted flows
  • Post review (9 inline comments + summary below)

Nice, focused feature. The refactor of startPurchaseProcessWithEmail into startPurchaseProcess(withPlan:) / (withProduct:) + shouldRestoreInsteadOfPurchase() + handlePurchase(result:) is a real readability win, dropping the dead signupAttemptCount/signupEmail is good housekeeping, and refreshProducts() now logging the failure instead of swallowing it with try? is an improvement. Removing the #if os(iOS) walls in Client.swift is safe — Client.store (line 53) and the whole InApp/ folder are already unconditional.

Three things I think are blocking, then some design/robustness points.

🔴 Blocking

1. Two AccountProvider mocks weren't updated — both test targets won't compile.
The new purchase(product:) requirement has no default implementation, and PIA VPNTests/Mocks/AccountProviderMock.swift:76 and PIA VPN-tvOSTests/Login/Helpers/AccountProviderMock.swift:95 don't implement it. Expect both CI test jobs to fail. (inline)

2. PurchaseIntentObserver.swift is not platform-guarded → tvOS build break.
PIALibrary builds for .tvOS(.v17) and nothing in InApp/ is #if os(iOS)-guarded, so this file compiles for tvOS — but StoreKit.PurchaseIntent isn't available there. Worse, if #available(iOS 16.4, *) evaluates as available on tvOS because of the trailing *, so tvOS takes the PurchaseIntent branch rather than the SK1 fallback. (inline)

3. Retain cycle in observePurchaseIntents() leaks the entire welcome flow.
guard let self else { return } sits before the for await, so the weak capture becomes a strong reference for the task's whole lifetime. The task ends only when the stream finishes → only in stop() → only from deinit → which can never run because the task holds self. The VC (with config, completionDelegate, collection view) leaks on every visit to the welcome screen, and the leaked instance keeps handling intents and calling perform(segue:) while detached from the window hierarchy. Suggested fix inline. (inline)

🟡 Design / robustness

4. Observation starts too late and lives in the wrong place. Both PurchaseIntent.intents and SKPaymentQueue.add(_:) are meant to be set up as early as possible in launch. Registering in GetStartedViewController.viewDidLoad means:

  • an intent delivered during cold launch can land before the welcome screen exists;
  • for an already-logged-in user there is no observer at all, so a tap on Buy in the App Store produces no feedback whatsoever;
  • if the welcome flow has pushed/presented something (login, restore), handlePurchase performs signupViaPurchaseSegue from a view controller that isn't topmost.

I'd start the observer in Bootstrapper (next to the existing Client.observeTransactions() at Bootstrapper.swift:182) and route the intent — to the purchase flow when unauthenticated, and to something sensible (or at least a log/KPI) when the user already has an account.

5. Observer lifecycle is single-use but not documented as such. stop() finishes the continuation permanently, so a later start() registers with StoreKit yet every yield is silently dropped; start() twice overwrites observeTask and double-registers with SKPaymentQueue. (inline)

6. Cancellation branch in start() logs "Task cancelled" when the real cause is a deallocated self, and calls stop() re-entrantly from the task — mutating observeTask off the main actor while start()/stop() are called from it. The package has no swiftSettings, so it's Swift 5 language mode and the compiler won't catch the race. (inline)

7. No re-entrancy guard on purchases. Neither confirmPlan() nor startPurchaseProcess(withProduct:) checks isPurchasing/isRestoring (unlike logInWithReceipt). An external purchase intent arriving mid-purchase starts a second StoreKit purchase and can fire the signup segue twice. (inline)

8. SK1 fallback can drop the purchase silently. You return false (discarding the queued SK1 payment), so if Product.products(for:) throws or returns empty the user gets nothing — and on iOS 15–16.3 this is the only code path. (inline)

🟢 Nits

  • hasIntroOffer: false hardcoded in both yields, vs. derived from isEligibleForIntroOffer in AppStoreProvider.fetchProducts. Not visible today; a trap for reuse. Looking the id up in Client.store.availableProducts would also validate that the identifier is one of ours. (inline)
  • @inlinable on DefaultAccountProvider.purchase(product:) buys nothing and is inconsistent with the rest of the type. (inline)
  • @MainActor on the startPurchaseProcess overloads and Task { @MainActor ... } in confirmPlan are redundant — UIViewController subclasses are already main-actor isolated.
  • case .failure(.userCancelled): log.debug(…); break — the break is dead after a statement.
  • // TODO: email has always been empty string, find out if we can remove (GetStartedViewController.swift:330) — behaviour is unchanged (it was "" before too), but worth a ticket rather than a bare TODO.

Security: nothing concerning. No credentials or secrets logged; the logs are product identifiers and transaction ids, and verification still goes through JWS + Product.PurchaseResult in AppStoreProvider. Note the SK1 path trusts the identifier from SKProduct only insofar as it hands it back to StoreKit — fine, though the availableProducts lookup in the nit above would make it explicit.

Test coverage

No tests were added, and the two existing mocks weren't updated (item 1). Worth adding:

  • PurchaseIntentObserver: stream terminates on stop(), start() idempotency, and the SK1 → SK2 identifier mapping (incl. the "product not found" path) — a StoreKitTest .storekit configuration would cover the last one.
  • PIA VPNTests: assert purchase(product:) is forwarded and that the mock records the product, plus the existing-entitlement branch (shouldRestoreInsteadOfPurchase) for the intent path, which is now shared between both entry points.

Flows to regression-test

  1. Promoted purchase from the App Store product page: (a) app not running, (b) backgrounded, (c) foregrounded on the welcome screen — on iOS 16.4+ and on an iOS 15/16.3 device for the SK1 path.
  2. Purchase intent while already signed in, and while the account is expired.
  3. Purchase intent when the App Store account already owns a subscription → should offer restore instead.
  4. Normal in-app plan selection → confirmPlan → signup (the main refactored path), including cancel, pending (Ask to Buy), and expired-transaction cases.
  5. Restore purchases + login-with-receipt (touched by the shared shouldRestoreInsteadOfPurchase).
  6. Repeated entry/exit of the welcome screen while watching for the leak in item 3 (Memory Graph / Instruments).
  7. tvOS signup/purchase flow — regression check after the Client.swift guard removal and the PurchaseIntentObserver platform fix.
  8. Mac Catalyst, if in scope: PurchaseIntent is available there via the iOS availability mapping.
    · branch KM-17274-purchase-intents

Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/InApp/PurchaseIntentObserver.swift Outdated
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/InApp/PurchaseIntentObserver.swift Outdated
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/InApp/PurchaseIntentObserver.swift Outdated
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/InApp/PurchaseIntentObserver.swift Outdated
Comment thread PIA VPN/UI/GetStartedViewController.swift
Comment thread PIA VPN/UI/GetStartedViewController.swift
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/InApp/PurchaseIntentObserver.swift Outdated
@kpkb-1f8e9813897fe9831983e89f7143

kpkb-1f8e9813897fe9831983e89f7143 Bot commented Jul 30, 2026

Copy link
Copy Markdown

KB review — 0f4c9c8

No findings changes on this PR relative to the default branch. ✅

View in KB

@kp-mario-nachbaur
kp-mario-nachbaur force-pushed the KM-17274-purchase-intents branch from dc24aef to 0f4c9c8 Compare July 30, 2026 14:26
@claude

This comment was marked as low quality.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @kp-mario-nachbaur's task in 4m 16s —— View job


Code review: purchase intents — re-review at 0f4c9c8

  • Gather context (re-read diff at latest commit)
  • Check status of previously raised findings
  • Analyze PurchaseIntentObserver
  • Analyze GetStartedViewController changes
  • Verify tvOS / Mac Catalyst build safety of the Client.swift guard removal
  • Check CI status
  • Post review (6 inline comments + summary below)

Good iteration — the three blocking items from the last round are resolved, and both test jobs are now green ("Run tests for iOS" and "Run tests for tvOS", run 30551717575 / 30551717654).

✅ Fixed since last review

# Item How
1 Missing purchase(product:) in both test mocks Stubs added in PIA VPNTests/Mocks/AccountProviderMock.swift:77 and PIA VPN-tvOSTests/Login/Helpers/AccountProviderMock.swift:98
2 tvOS build break #if !os(tvOS) wraps the whole file. Correct choice: PIALibrary platforms are .iOS(.v15), .tvOS(.v17), .macCatalyst(.v15), and Mac Catalyst maps to the iOS availability domain so #available(iOS 16.4, *) behaves properly there
3 Retain cycle leaking the whole welcome flow intents is now bound once and self?. used per-iteration, so no strong self is held for the task's lifetime and deinit can actually run
7 No re-entrancy guard on purchases guard !isPurchasing, !isRestoring added to both startPurchaseProcess overloads and to logInWithReceipt
stop() permanently killing the stream continuation.finish() dropped; the consumer's for await now unwinds via task cancellation, and the observer is restartable

I also verified the #if os(iOS) removal in Client.swift is safe: AccountProvider.listPlanProducts/purchase live under #if os(iOS) || os(tvOS) (AccountProvider.swift:180), Client.store is unconditional (Client.swift:53), and refreshProducts/observeTransactions/dispose are only called from the iOS Bootstrapper — so no tvOS behaviour change.

🟡 Still open

4. Observation starts too late / lives in the wrong place — unchanged. Both PurchaseIntent.intents and SKPaymentQueue.add(_:) are documented as "set up as early as possible at launch". Registering in GetStartedViewController.viewDidLoad means a logged-in user has no observer at all, so a tap on Buy in the App Store produces no feedback whatsoever; and if the welcome flow has pushed login/restore, handlePurchase fires signupViaPurchaseSegue from a VC that isn't topmost. Bootstrapper.swift:182, next to Client.observeTransactions(), is where I'd put this. (inline)

5. start() still isn't idempotent — calling it twice overwrites observeTask (leaking the first, uncancelled task) on 16.4+, or double-registers with SKPaymentQueue on 15–16.3 while stop() removes once. Also no deinit finishing the continuation. (inline)

6. Cancellation branch in start() — logs "Task cancelled" when the real cause is a deallocated self, and calls stop() re-entrantly, writing observeTask off the main actor while start()/stop() are called from it. I confirmed the app target is SWIFT_VERSION = 5.0 and the package sets no swiftSettings, so the compiler won't flag this race. (inline)

8. SK1 fallback can drop the purchase silently — you return false (discarding the queued SK1 payment), so if Product.products(for:) throws or returns empty the user gets nothing. On iOS 15–16.3 this is the only promoted-purchase path. (inline)

🟡 New / smaller points

  • Nonisolated access to main-actor state: self?.purchaseIntentObserver is read from a nonisolated Task and PurchaseIntentObserver isn't Sendable. Harmless under Swift 5 mode, an error the day the target moves to Swift 6 (which CLAUDE.md lists as the project's language). Hoisting let intents = purchaseIntentObserver.purchaseIntents out of the closure fixes it and reads better. (inline)
  • shouldRestoreInsteadOfPurchase() silently resets isPurchasing (line 276) — a side-effecting predicate; the call sites read as pure checks. (inline)
  • isPurchasing = false before perform(segue:) — an intent arriving right after a successful purchase can start a second purchase behind the signup screen.
  • The two startPurchaseProcess overloads are byte-identical apart from one line — worth collapsing into a shared body taking the purchase closure, so the guard/loading/handle sequence can't drift. (inline)

🟢 Nits

  • @inlinable on DefaultAccountProvider.purchase(product:) still buys nothing and is inconsistent with the rest of the type. (inline)
  • hasIntroOffer: false now carries a comment explaining it's unused — good enough for now, though a lookup in Client.store.availableProducts would also validate the incoming identifier is one of ours before starting a purchase.
  • Redundant @MainActor on the overloads and Task { @MainActor ... } in confirmPlanUIViewController subclasses are already main-actor isolated.
  • case .failure(.userCancelled): log.debug(…); break — the break is dead after a statement.
  • // TODO: email has always been empty string, find out if we can remove (GetStartedViewController.swift:340) — behaviour is unchanged, but worth a ticket rather than a bare TODO.
  • observePurchaseIntents() is called before super.viewDidLoad(); pre-existing ordering quirk in this file, but the new call inherits it.
  • Inner product on PurchaseIntentObserver.swift:84 shadows the SKProduct parameter with a StoreKit.Productsk2Product would read better.

Security: nothing concerning. The new logs carry product identifiers and transaction ids only — no credentials or receipts. Verification still goes through JWS + Product.PurchaseResult in AppStoreProvider.

Test coverage

Still no tests added. The mocks compile but discard the argument, so nothing about the new path is assertable. Worth adding:

  • PurchaseIntentObserver: start() idempotency, termination on cancellation, and the SK1 → SK2 identifier mapping including the "product not found" path (a StoreKitTest .storekit configuration covers the last one).
  • PIA VPNTests: record purchase(product:) on the mock and assert forwarding, plus the re-entrancy guards and the shouldRestoreInsteadOfPurchase branch now shared by both entry points.

Flows to regression-test

  1. Promoted purchase from the App Store page: (a) app not running, (b) backgrounded, (c) foregrounded on the welcome screen — on iOS 16.4+ and an iOS 15/16.3 device for the SK1 path.
  2. Purchase intent while already signed in, and with an expired account (expect nothing to happen today — see item 4).
  3. Purchase intent when the App Store account already owns a subscription → restore prompt.
  4. Normal plan selection → confirmPlan → signup, including cancel, pending (Ask to Buy), and expired-transaction cases.
  5. Restore purchases + login-with-receipt (touched by the shared shouldRestoreInsteadOfPurchase and the new !isPurchasing guard).
  6. Repeated entry/exit of the welcome screen — confirm deinit now fires and the VC deallocates (Memory Graph).
  7. tvOS signup/purchase flow — sanity check after the Client.swift guard removal (tvOS unit tests already pass).
  8. Mac Catalyst: PurchaseIntent is in scope there via the iOS availability mapping, and the file is no longer excluded — worth one manual pass.

· branch KM-17274-purchase-intents

Comment on lines +52 to +56
guard let self, !Task.isCancelled else {
log.debug("Task cancelled, stopping")
self?.stop()
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still open from the previous round — the #if !os(tvOS) guard fixed the availability problem, but this guard body has three issues:

  • Misleading log: the else branch is also taken when self was deallocated, which then logs "Task cancelled, stopping".
  • Re-entrant stop() off the main actor: stop() cancels the very task it's called from and writes observeTask = nil, while start()/stop() are invoked from the main actor (GetStartedViewController). observeTask is unsynchronized mutable state on a non-Sendable class → data race. The app target is SWIFT_VERSION = 5.0 and the package has no swiftSettings, so the compiler stays silent about it.
  • Redundant: for await already unwinds on cancellation, and whoever cancelled is the one that called stop().
Suggested change
guard let self, !Task.isCancelled else {
log.debug("Task cancelled, stopping")
self?.stop()
break
}
guard let self else { break }

Fix this →

Comment on lines +67 to +71
public func stop() {
observeTask?.cancel()
observeTask = nil
SKPaymentQueue.default().remove(self)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good change — dropping continuation.finish() here is what makes the object restartable and lets the consumer's for await terminate purely via task cancellation. Two leftovers:

  • start() is still not idempotent: calling it twice overwrites observeTask (leaking the first, uncancelled task) on 16.4+, or adds self to SKPaymentQueue twice on 15–16.3 while stop() removes it once. guard observeTask == nil else { return } covers the SK2 side; the SK1 side needs its own flag since it doesn't set observeTask.
  • No deinit finishing the continuation. Today the VC cancels its task explicitly, so nothing hangs — but if a future consumer forgets, the for await never ends. A deinit { continuation.finish() } (plus stop()) would make that safe by construction.

Comment on lines +129 to +138
private func observePurchaseIntents() {
purchaseIntentObserver.start()
purchaseIntentsTask = Task { [weak self] in
guard let intents = self?.purchaseIntentObserver.purchaseIntents else { return }
for await product in intents {
if Task.isCancelled { break }
log.debug("Purchase intent received for product id: \(product.identifier)")
await self?.startPurchaseProcess(withProduct: product)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Retain cycle resolved — binding intents once and using self?. inside the loop means no strong self is held for the task's lifetime, so deinit can now actually run and cancel the task. Nice.

Two remaining notes:

  1. Nonisolated access to main-actor state. self?.purchaseIntentObserver is read from a nonisolated Task, and PurchaseIntentObserver isn't Sendable. Under SWIFT_VERSION = 5.0 this is at most a warning, but it will be an error whenever the target moves to Swift 6 mode. Hoisting the stream out before the task avoids both the optional dance and the isolation crossing:
Suggested change
private func observePurchaseIntents() {
purchaseIntentObserver.start()
purchaseIntentsTask = Task { [weak self] in
guard let intents = self?.purchaseIntentObserver.purchaseIntents else { return }
for await product in intents {
if Task.isCancelled { break }
log.debug("Purchase intent received for product id: \(product.identifier)")
await self?.startPurchaseProcess(withProduct: product)
}
}
private func observePurchaseIntents() {
purchaseIntentObserver.start()
let intents = purchaseIntentObserver.purchaseIntents
purchaseIntentsTask = Task { [weak self] in
for await product in intents {
if Task.isCancelled { break }
log.debug("Purchase intent received for product id: \(product.identifier)")
await self?.startPurchaseProcess(withProduct: product)
}
}
}
  1. Timing still worries me (top-level item 4 from the last round, unaddressed): both PurchaseIntent.intents and SKPaymentQueue.add(_:) are documented as "set up as early as possible in launch". Registering in viewDidLoad of the welcome screen means a logged-in user has no observer at all, so tapping Buy on the App Store page produces zero feedback. Bootstrapper.swift:182 (next to Client.observeTransactions()) is where I'd put this.

Comment on lines +204 to 232
private func startPurchaseProcess(withPlan plan: PurchasePlan) async {
guard !isPurchasing, !isRestoring else {
log.debug("Ignoring purchase request, purchase or restore already in progress")
return
}
isPurchasing = true
self.handleLoadingState()
handleLoadingState()
if await shouldRestoreInsteadOfPurchase() { return }
let result = await config.accountProvider.purchase(plan: plan.plan)
isPurchasing = false
handleLoadingState()
await handlePurchase(result: result)
}

Task { [weak self] in
guard let self else { return }
@MainActor
private func startPurchaseProcess(withProduct product: any InAppProduct) async {
guard !isPurchasing, !isRestoring else {
log.debug("Ignoring purchase request, purchase or restore already in progress")
return
}
isPurchasing = true
handleLoadingState()
if await shouldRestoreInsteadOfPurchase() { return }
let result = await config.accountProvider.purchase(product: product)
isPurchasing = false
handleLoadingState()
await handlePurchase(result: result)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Re-entrancy guards added on both overloads and on logInWithReceipt — that closes the "promoted purchase arrives mid-purchase" hole.

Two follow-ups now that the two bodies are byte-identical apart from one line:

  1. shouldRestoreInsteadOfPurchase() silently owns the isPurchasing reset. Reading if await shouldRestoreInsteadOfPurchase() { return } at the call site, it isn't obvious the flag gets cleared inside (line 276) — a side-effecting predicate. It'd be clearer to have the predicate be pure and reset/handleLoadingState()/alertExistingEntitlement() at the call site, or rename it to something that admits the side effect.

  2. isPurchasing = false is set before perform(segue:) in handlePurchase, so an intent arriving right after a successful purchase can kick off a second purchase behind the signup screen. Holding the flag until signup completes (or gating on presentedViewController == nil) would close that.

Also, the two overloads could collapse into one shared body:

private func startPurchaseProcess(_ purchase: () async -> Result<any InAppTransaction, ClientError>) async { ... }

with the two public entry points just supplying the closure. Optional, but it removes a copy-paste pair that has to stay in sync.

Nit: @MainActor on both is redundant — UIViewController subclasses are already main-actor isolated, as are handlePurchase and shouldRestoreInsteadOfPurchase, and so is the Task { @MainActor [weak self] in ... } in confirmPlan.

Comment thread PIA VPNTests/Mocks/AccountProviderMock.swift
@kp-mario-nachbaur
kp-mario-nachbaur marked this pull request as draft August 3, 2026 08:20
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