feat(ios): sync paywall with external purchased items (#13681)

This pull request introduces significant improvements to the integration
between the paywall feature and the web context within the iOS app. The
main focus is on enabling synchronization of subscription states between
the app and the embedded web view, refactoring how purchased items are
managed, and enhancing the paywall presentation logic. Additionally,
some debug-only code has been removed for cleaner production builds.

**Paywall and Web Context Integration**

* Added support for binding a `WKWebView` context to the paywall,
allowing the paywall to communicate with the web view for subscription
state updates and retrievals (`Paywall.presentWall` now accepts a
`bindWebContext` parameter, and `ViewModel` supports binding and using
the web context).
[[1]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR10-R32)
[[2]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0R54-R57)
[[3]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0L26-R38)
[[4]](diffhunk://#diff-1854d318d8fd8736d078f5960373ed440836263649a8193c8ee33e72a99424edL30-R36)

* On paywall dismissal, the app now triggers a JavaScript call to update
the subscription state in the web view, ensuring consistency between the
app and the web context.

**Purchased Items Refactor**

* Refactored `ViewModel` to distinguish between store-purchased items
and externally-purchased items (from the web context), and unified them
in a computed `purchasedItems` property. This improves clarity and
extensibility for handling entitlements from multiple sources.

* Added logic to fetch external entitlements by executing JavaScript in
the web view and decoding the subscription information, mapping external
plans to internal product identifiers.
[[1]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL99-R137)
[[2]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbR169-R209)

**Codebase Cleanup**

* Removed debug-only code for shake gesture and debug menu from
`AFFiNEViewController`, streamlining the production build.

**API and Model Enhancements**

* Made `SKUnitCategory` and its extensions public to allow broader usage
across modules, and introduced a configuration struct for the paywall.
[[1]](diffhunk://#diff-742ccf0c6bafd2db6cb9795382d556fbab90b8855ff38dc340aa39318541517dL10-R17)
[[2]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR10-R32)

**Other Minor Improvements**

* Improved constructor formatting for `PayWallPlugin` for readability.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Paywall now binds to the in-app web view so web-based subscriptions
are recognized alongside App Store purchases.
- Bug Fixes
- Entitlements combine App Store and web subscription state for more
accurate display.
- Dismissing the paywall immediately updates subscription status to
reduce stale states.
  - Improved reliability when presenting the paywall.
- Chores
  - Removed debug shake menu and debug paywall options from iOS builds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-10-03 15:21:41 +08:00
committed by GitHub
parent 856b69e1f6
commit 7d0b8aaa81
7 changed files with 1616 additions and 1645 deletions
@@ -74,29 +74,4 @@ class AFFiNEViewController: CAPBridgeViewController {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
#if DEBUG
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
showDebugMenu()
}
}
#endif
}
#if DEBUG
import AffinePaywall
extension AFFiNEViewController {
@objc private func showDebugMenu() {
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
Paywall.presentWall(toController: self, type: "Pro")
})
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
Paywall.presentWall(toController: self, type: "AI")
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
}
}
#endif
@@ -6,7 +6,9 @@ import UIKit
@objc(PayWallPlugin)
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
init(associatedController: UIViewController? = nil) {
init(
associatedController: UIViewController?
) {
controller = associatedController
super.init()
}
@@ -27,7 +29,11 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
// TODO: GET TO KNOW THE PAYWALL TYPE
print("[*] showing paywall of type: \(type)")
DispatchQueue.main.async {
Paywall.presentWall(toController: controller, type: type)
Paywall.presentWall(
toController: controller,
bindWebContext: self.webView,
type: type
)
}
call.resolve(["success": true, "type": type])
File diff suppressed because it is too large Load Diff
@@ -7,14 +7,14 @@
import Foundation
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
var id: Int { rawValue }
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
public var id: Int { rawValue }
case pro
case ai
}
extension SKUnitCategory {
public extension SKUnitCategory {
var title: String {
switch self {
case .pro: "AFFINE.Pro"
@@ -75,6 +75,21 @@ extension ViewModel {
func dismiss() {
print(#function)
if let context = associatedWebContext {
Task.detached {
do {
_ = try await context.callAsyncJavaScript(
"return await window.updateSubscriptionState();",
contentWorld: .page
)
print("updateSubscriptionState success")
} catch {
print("updateSubscriptionState error:", error.localizedDescription)
}
}
}
associatedController?.dismiss(animated: true)
}
}
@@ -96,12 +111,30 @@ nonisolated extension ViewModel {
// fetch purchased items if signed in
do {
let purchase = try await store.fetchEntitlements()
await MainActor.run { self.purchasedItems = purchase }
await MainActor.run { self.storePurchasedItems = purchase }
} catch {
print("fetchEntitlements error:", error)
if !initial { throw error }
}
// fetch external items by executing on webview's JS context
do {
guard let webView = await associatedWebContext else {
throw NSError(domain: "Paywall", code: -1, userInfo: [
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
])
}
let result = try await webView.callAsyncJavaScript(
"return await window.getSubscriptionState();",
contentWorld: .page
)
let purchased = decodeWebContextSubscriptionInformation(result)
print("fetched external purchased items:", purchased)
await MainActor.run { self.externalPurchasedItems = purchased }
} catch {
print("fetchExternalEntitlements error:", error.localizedDescription)
}
// select the package under purchased items if any
let availablePackages = await availablePackageOptions
let purchase = await purchasedItems
@@ -133,4 +166,45 @@ nonisolated extension ViewModel {
await MainActor.run { self.updating = false }
}
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
var ans: Set<String> = []
guard let dict = input as? [String: Any] else {
assertionFailure()
return ans
}
let pro = dict["pro"] as? [String: Any]
let ai = dict["ai"] as? [String: Any]
if let proPlan = pro?["recurring"] as? String {
switch proPlan.lowercased() {
case "lifetime":
// user actually purchased believer plan
// but we map it to yearly plan just for easier handling
// do not purchase any of this plan if already purchased
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
case "monthly":
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
case "yearly":
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
default:
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
assertionFailure()
}
}
if let aiPlan = ai?["recurring"] as? String {
switch aiPlan.lowercased() {
case "yearly":
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
default:
// ai plan can only be purchased as yearly plan
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
assertionFailure()
}
}
return ans
}
}
@@ -7,6 +7,7 @@
import StoreKit
import SwiftUI
import WebKit
@MainActor
class ViewModel: ObservableObject {
@@ -23,10 +24,18 @@ class ViewModel: ObservableObject {
@Published var updating = false
@Published var products: [Product] = []
@Published var purchasedItems: Set<String> = []
@Published var storePurchasedItems: Set<String> = []
@Published var externalPurchasedItems: Set<String> = []
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
var purchasedItems: Set<String> {
Set<String>()
.union(storePurchasedItems)
.union(externalPurchasedItems)
}
private(set) weak var associatedController: UIViewController?
private(set) weak var associatedWebContext: WKWebView?
init() {
updateAppStoreStatus(initial: true)
@@ -42,6 +51,10 @@ class ViewModel: ObservableObject {
associatedController = controller
}
func bind(context: WKWebView) {
associatedWebContext = context
}
func select(category: SKUnitCategory) {
self.category = category
let units = SKUnit.units(for: category)
@@ -7,14 +7,17 @@
import SwiftUI
import UIKit
import WebKit
public enum Paywall {
@MainActor
public static func presentWall(
toController controller: UIViewController,
bindWebContext context: WKWebView?,
type: String
) {
let viewModel = ViewModel()
if let context { viewModel.bind(context: context) }
switch type.lowercased() {
case "pro":
viewModel.select(category: .pro)