How to deal with StoreKit 2 on Airplane mode or offline?

900 views Asked by At

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
    }

}
1

There are 1 answers

0
Joey C. On

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 of Product. Offline, you still have access to Transaction.currentEntitlements. Unfortunately, currentEntitlements will only contain a transaction for an active subscription and is empty when the subscription is expired, revoked, etc.

e.g.

@MainActor
func updateCustomerProductStatus() async {
    var purchasedSubscriptions: [Transaction] = []

    // Iterate through all of the user's purchased products.
    for await result in Transaction.currentEntitlements {
        do {
            let transaction = try checkVerified(result)

            switch transaction.productType {
            case .autoRenewable:
                purchasedSubscriptions.append(transaction)
            default:
                continue
            }
        } catch {
            DLog("Failed to verify entitlement transaction")
        }
    }

    self.purchasedSubscriptions = purchasedSubscriptions
}

// Use Transaction.productID to determine tier of service. e.g.
purchasedSubscriptions.first?.productID == "your.pro.identifier"