I'm trying to add/manage my first in-app purchase (non-consumable) on my iOS app and I just discovered that StoreKit 2 doesn't work well offline.
These past days, I used to display (or not) the premium features based on store.purchasedItems.isEmpty
but this doesn't work at all on Airplane mode.
I mean, I understand that some parts of my Store
file can't be accessible offline. The fetch request from the App Store can only works online, for example. But I didn't expected to be the same about the purchasedItems
.
So, I'm wondering what should I do instead? Maybe displaying (or not) the premium features based on an @AppStorage
variable? If yes, where should I toggle it? So many questions, I'm quite lost.
Here's my Store
file, it's a "light" version from the WWDC21 StoreKit 2 Demo:
import Foundation
import StoreKit
typealias Transaction = StoreKit.Transaction
public enum StoreError: Error {
case failedVerification
}
class Store: ObservableObject {
@Published private(set) var items: [Product]
@Published private(set) var purchasedItems: [Product] = []
var updateListenerTask: Task<Void, Error>? = nil
init() {
items = []
updateListenerTask = listenForTransactions()
Task {
await requestProducts()
await updateCustomerProductStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
await self.updateCustomerProductStatus()
await transaction.finish()
} catch {
print()
}
}
}
}
@MainActor
func requestProducts() async {
do {
let storeItems = try await Product.products(for: [ /* IAP */ ])
var newItems: [Product] = []
for item in storeItems {
newItems.append(item)
}
items = newItems
} catch {
print()
}
}
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updateCustomerProductStatus()
await transaction.finish()
return transaction
case .userCancelled, .pending:
return nil
default:
return nil
}
}
func isPurchased(_ product: Product) async throws -> Bool {
purchasedItems.contains(product)
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
return safe
case .unverified:
throw StoreError.failedVerification
}
}
@MainActor
func updateCustomerProductStatus() async {
var purchasedItems: [Product] = []
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
if let product = products.first(where: { $0.id == transaction.productID }) {
purchasedItems.append(product)
}
} catch {
print()
}
}
self.purchasedItems = purchasedItems
}
}
I'm running into the same questions implementing subscriptions with StoreKit 2. The sample code is oriented around the
Product
object, and it looks like the only way to access these objects is to fetch them from the App Store with a time-consuming async request. I really don't want to block critical functionality of my app by waiting for this request to finish.So far, I've found the best way to guarantee offline access to a customer's purchase status is to ignore the sample code and use
Transaction
instead ofProduct
. Offline, you still have access toTransaction.currentEntitlements
. Unfortunately,currentEntitlements
will only contain a transaction for an active subscription and is empty when the subscription is expired, revoked, etc.e.g.