89b35dc4ae
- Switch premium to AutoRenewable subscription (com.mealmood.premium.monthly.sub); legacy one-time purchasers retain lifetime access via StoreManager fallback - Add ITSAppUsesNonExemptEncryption=false to Info.plist (no custom encryption) - Update storekit config with both legacy NonConsumable and new subscription - Bump version 1.1.1 build 46; update fastlane metadata and release notes (6 langs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
217 lines
6.7 KiB
Swift
217 lines
6.7 KiB
Swift
import StoreKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class StoreManager: ObservableObject {
|
|
enum ProductLoadState {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case timedOut
|
|
case notFound
|
|
case failed
|
|
}
|
|
|
|
enum PurchaseResult {
|
|
case success
|
|
case cancelled
|
|
case pending
|
|
case failed
|
|
}
|
|
|
|
@Published var products: [Product] = []
|
|
@Published var isPremium: Bool = false
|
|
@Published var isLoading: Bool = false
|
|
@Published var isLoadingProducts: Bool = false
|
|
@Published var productLoadState: ProductLoadState = .idle
|
|
@Published var debugLoadedProductIds: [String] = []
|
|
@Published var debugLoadedProducts: [String] = []
|
|
|
|
static let monthlyProductId = "com.mealmood.premium.monthly.sub"
|
|
static let legacyOneTimeProductId = "com.mealmood.premium.monthly"
|
|
private var productIds: [String] {
|
|
var ids = [Self.monthlyProductId, Self.legacyOneTimeProductId]
|
|
|
|
if let bundleId = Bundle.main.bundleIdentifier {
|
|
ids.append("\(bundleId).premium.monthly.sub")
|
|
ids.append("\(bundleId).premium.monthly")
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
return ids.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
init() {
|
|
Task {
|
|
await loadProducts()
|
|
await checkPremiumStatus()
|
|
}
|
|
}
|
|
|
|
func loadProducts() async {
|
|
isLoadingProducts = true
|
|
productLoadState = .loading
|
|
defer { isLoadingProducts = false }
|
|
do {
|
|
let fetchedProducts = try await loadProductsWithRetries()
|
|
products = fetchedProducts
|
|
debugLoadedProductIds = fetchedProducts.map(\.id)
|
|
debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" }
|
|
productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded
|
|
} catch {
|
|
if error is TimeoutError {
|
|
productLoadState = .timedOut
|
|
} else {
|
|
productLoadState = .failed
|
|
}
|
|
print("Failed to load products: \(error)")
|
|
}
|
|
}
|
|
|
|
func purchase(_ product: Product) async -> PurchaseResult {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
if case .verified(let transaction) = verification {
|
|
await transaction.finish()
|
|
isPremium = true
|
|
AnalyticsService.logPremiumPurchased()
|
|
return .success
|
|
}
|
|
return .failed
|
|
case .userCancelled:
|
|
return .cancelled
|
|
case .pending:
|
|
return .pending
|
|
@unknown default:
|
|
return .failed
|
|
}
|
|
} catch {
|
|
print("Purchase failed: \(error)")
|
|
return .failed
|
|
}
|
|
}
|
|
|
|
func restorePurchases() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
try await AppStore.sync()
|
|
let wasNotPremium = !isPremium
|
|
await checkPremiumStatus()
|
|
if wasNotPremium && isPremium {
|
|
AnalyticsService.logPremiumRestored()
|
|
}
|
|
} catch {
|
|
print("Restore failed: \(error)")
|
|
}
|
|
}
|
|
|
|
private func checkPremiumStatus() async {
|
|
isPremium = await Self.hasActiveSubscription()
|
|
}
|
|
|
|
var monthlyProduct: Product? {
|
|
if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) {
|
|
return match
|
|
}
|
|
if let match = products.first(where: { $0.type == .autoRenewable }) {
|
|
return match
|
|
}
|
|
if let match = products.first(where: { $0.id == Self.legacyOneTimeProductId }) {
|
|
return match
|
|
}
|
|
return products.first
|
|
}
|
|
|
|
var debugProductIds: [String] { productIds }
|
|
|
|
static func hasActiveSubscription() async -> Bool {
|
|
let bundleId = Bundle.main.bundleIdentifier ?? ""
|
|
let subscriptionIds: Set<String> = [monthlyProductId, "\(bundleId).premium.monthly.sub"]
|
|
// Legacy one-time purchases grant lifetime access — no expiration check needed.
|
|
let legacyIds: Set<String> = [legacyOneTimeProductId, "\(bundleId).premium.monthly"]
|
|
|
|
for await result in Transaction.currentEntitlements {
|
|
guard case .verified(let transaction) = result,
|
|
transaction.revocationDate == nil else { continue }
|
|
|
|
if legacyIds.contains(transaction.productID) {
|
|
return true
|
|
}
|
|
|
|
if subscriptionIds.contains(transaction.productID) {
|
|
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private struct TimeoutError: Error {}
|
|
|
|
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
|
|
var lastProducts: [Product] = []
|
|
|
|
for attempt in 1...maxAttempts {
|
|
let products = try await loadProductsWithTimeout(seconds: 12)
|
|
if !products.isEmpty {
|
|
return products
|
|
}
|
|
lastProducts = products
|
|
|
|
if attempt < maxAttempts {
|
|
try await Task.sleep(nanoseconds: 800_000_000)
|
|
}
|
|
}
|
|
|
|
return lastProducts
|
|
}
|
|
|
|
private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] {
|
|
let ids = productIds
|
|
return try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
group.addTask {
|
|
try await self.loadProductsBySingleID(ids: ids)
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
|
throw TimeoutError()
|
|
}
|
|
|
|
let firstResult = try await group.next() ?? []
|
|
group.cancelAll()
|
|
return firstResult
|
|
}
|
|
}
|
|
|
|
private func loadProductsBySingleID(ids: [String]) async throws -> [Product] {
|
|
var merged: [String: Product] = [:]
|
|
|
|
// First, attempt a single batch request with all IDs.
|
|
let batchItems = try await Product.products(for: ids)
|
|
for item in batchItems {
|
|
merged[item.id] = item
|
|
}
|
|
if !merged.isEmpty {
|
|
return Array(merged.values)
|
|
}
|
|
|
|
// Fallback to one-by-one requests for better resilience/debugging.
|
|
for id in ids {
|
|
let items = try await Product.products(for: [id])
|
|
for item in items {
|
|
merged[item.id] = item
|
|
}
|
|
}
|
|
return Array(merged.values)
|
|
}
|
|
}
|