From 5ba7ebb994f9b7f405beb5e191e79da966200bfb Mon Sep 17 00:00:00 2001 From: Russell Archer Date: Tue, 11 Apr 2023 17:04:39 +0100 Subject: [PATCH] Fixed auto-sub renew issues when app's not running (didn't affect prod) Also added transaction ids to logging; fixed purchased products fallback list persistence --- Documentation/guide.md | 16 ++++--- Sources/StoreHelper/Core/AppStoreHelper.swift | 19 ++++---- Sources/StoreHelper/Core/KeychainHelper.swift | 6 +-- Sources/StoreHelper/Core/StoreHelper.swift | 43 ++++++++++++------- Sources/StoreHelper/Core/StoreLog.swift | 26 ++++++----- 5 files changed, 66 insertions(+), 44 deletions(-) diff --git a/Documentation/guide.md b/Documentation/guide.md index 0815931bf..ee205b83d 100644 --- a/Documentation/guide.md +++ b/Documentation/guide.md @@ -429,7 +429,7 @@ Running the app's iOS target produces: ![](./assets/StoreHelperDemo11.png) -Note that prices are in US dollars. This is because, by default in test environment, the App Store `Storefront` is **United States (USD)** and the localization is **English (US)**. To support testing other locales you can change this. Make sure the `Products.storekit` file is open, then select **Editor > Default Storefront** and change this to another value. You can also changed the localization from **English (US**) with **Editor > Default Localization**. +Note that prices are in US dollars. This is because, by default in the test environment, the App Store `Storefront` is **United States (USD)** and the localization is **English (US)**. To support testing other locales you can change this. Make sure the `Products.storekit` file is open, then select **Editor > Default Storefront** and change this to another value. You can also changed the localization from **English (US**) with **Editor > Default Localization**. Here I selected **United Kingdom (GBP)** as the storefront and **English (UK)** as the localization. I also created some UK-specific descriptions of the products. Notice how prices are now in UK Pounds: @@ -1211,12 +1211,14 @@ struct ContentView: View { @State private var productId: ProductId = "" var body: some View { - Products() { productId, promoId in - // Get the app-server to sign the promotional offer with your App Store Connect key - return getSignature(productId: productId, promotionId: promoId) - - } productInfoCompletion: { id in - // User wants more info on a product. Show the info sheet... + ScrollView { + Products() { productId, promoId in + // Get the app-server to sign the promotional offer with your App Store Connect key + return getSignature(productId: productId, promotionId: promoId) + + } productInfoCompletion: { id in + // User wants more info on a product. Show the info sheet... + } } } diff --git a/Sources/StoreHelper/Core/AppStoreHelper.swift b/Sources/StoreHelper/Core/AppStoreHelper.swift index f94b0e849..68b9f8880 100644 --- a/Sources/StoreHelper/Core/AppStoreHelper.swift +++ b/Sources/StoreHelper/Core/AppStoreHelper.swift @@ -8,11 +8,11 @@ import StoreKit /// Support for StoreKit1. Tells the observer that a user initiated an in-app purchase direct from the App Store, -/// rather than via the app itself. StoreKit2 does not (yet) provide support for this feature so we need to use -/// StoreKit1. This is a requirement in order to promote in-app purchases on the App Store. If your app doesn't -/// have a class that implements `SKPaymentTransactionObserver` and the `paymentQueue(_:updatedTransactions:)` -/// and `paymentQueue(_:shouldAddStorePayment:for:)` delegate methods then you'll get an error when you submit -/// the app to the App Store and you have IAP promotions. +/// rather than via the app itself. Also picks up subscriptions auto-renewals. StoreKit2 does not (yet) provide +/// support for this feature so we need to use StoreKit1. This is a requirement in order to promote in-app purchases +/// on the App Store. If your app doesn't have a class that implements `SKPaymentTransactionObserver` and the +/// `paymentQueue(_:updatedTransactions:)` and `paymentQueue(_:shouldAddStorePayment:for:)` delegate methods then +/// you'll get an error when you submit the app to the App Store and you have IAP promotions. /// /// Note that any IAPs made from **inside** the app are processed by StoreKit2 and do not involve this helper class. @available(iOS 15.0, macOS 12.0, *) @@ -35,8 +35,8 @@ public class AppStoreHelper: NSObject, SKPaymentTransactionObserver { /// Delegate method for the StoreKit1 payment queue. Note that because our main StoreKit processing is done /// via StoreKit2 in StoreHelper, all we have to do here is signal to StoreKit1 to finish purchased, restored - /// or failed transactions. StoreKit1 purchases are immediately available to StoreKit2 (and vice versa), so - /// any purchase will be picked up by StoreHelper as required. + /// or failed transactions. StoreKit1 purchases are (in theory) immediately available to StoreKit2 (and vice + /// versa), so any purchase will be picked up by StoreHelper as required. /// - Parameters: /// - queue: StoreKit1 payment queue /// - transactions: Collection of updated transactions (e.g. `purchased`) @@ -45,7 +45,10 @@ public class AppStoreHelper: NSObject, SKPaymentTransactionObserver { switch (transaction.transactionState) { case .purchased: SKPaymentQueue.default().finishTransaction(transaction) - Task.init { await storeHelper?.productPurchased(transaction.payment.productIdentifier) } // Tell StoreKit2-based StoreHelper about purchase + + // Let the StoreKit2-based StoreHelper know about this purchase or subscription renewal + Task { await storeHelper?.productPurchased(transaction.payment.productIdentifier, transactionId: transaction.transactionIdentifier ?? "0") } + case .restored: fallthrough case .failed: SKPaymentQueue.default().finishTransaction(transaction) default: break diff --git a/Sources/StoreHelper/Core/KeychainHelper.swift b/Sources/StoreHelper/Core/KeychainHelper.swift index d37028ddd..5fe08ff06 100644 --- a/Sources/StoreHelper/Core/KeychainHelper.swift +++ b/Sources/StoreHelper/Core/KeychainHelper.swift @@ -59,7 +59,7 @@ public struct KeychainHelper { // Create a query of what we want to search for. Note we don't restrict the search (kSecMatchLimitAll) let query = [kSecClass as String : kSecClassGenericPassword, kSecAttrAccount as String : productId, - kSecMatchLimit as String: kSecMatchLimitOne] as CFDictionary + kSecMatchLimit as String: kSecMatchLimitOne] as [String : Any] as CFDictionary // Search for the item in the keychain var item: CFTypeRef? @@ -77,7 +77,7 @@ public struct KeychainHelper { kSecAttrAccount as String : productId, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, - kSecReturnData as String: true] as CFDictionary + kSecReturnData as String: true] as [String : Any] as CFDictionary var item: CFTypeRef? let status = SecItemCopyMatching(query, &item) @@ -131,7 +131,7 @@ public struct KeychainHelper { let query = [kSecClass as String : kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true, - kSecReturnData as String: true] as CFDictionary + kSecReturnData as String: true] as [String : Any] as CFDictionary // Search for all the items created by this app in the keychain var item: CFTypeRef? diff --git a/Sources/StoreHelper/Core/StoreHelper.swift b/Sources/StoreHelper/Core/StoreHelper.swift index 6f09ce3dc..f49452e50 100644 --- a/Sources/StoreHelper/Core/StoreHelper.swift +++ b/Sources/StoreHelper/Core/StoreHelper.swift @@ -278,14 +278,14 @@ public class StoreHelper: ObservableObject { updatePurchasedProducts(for: productId, purchased: purchased, updateFallbackList: false, updateTransactionCheck: false) return purchased } - + // Make sure we're listening for transactions, the App Store is available, we have a list of localized products // and that we can create a `Product` from the `ProductId`. If not, we have to rely on the cache of purchased products guard hasStarted, isAppStoreAvailable, hasProducts, let product = product(from: productId) else { StoreLog.event(.appStoreNotAvailable) purchased = purchasedProductsFallback.contains(productId) StoreLog.event(purchased ? .productIsPurchasedFromCache : .productIsNotPurchased, productId: productId) - return purchasedProductsFallback.contains(productId) + return purchased } // Is this a consumable product? We need to treat consumables differently because their transactions are NOT stored in the receipt @@ -301,6 +301,16 @@ public class StoreHelper: ObservableObject { // There's no transaction for the product, so it hasn't been purchased. However, the App Store does sometimes return nil, // even if the user is entitled to access the product. For this reason we don't update the fallback cache and transaction // check list for a negative response + + // If this is a subscription product, before giving up see if it was renewed while the app was offline and the + // transaction hasn't yet been sent to us + if isSubscription(productId: productId) { + if purchasedProductsFallback.contains(productId) { + StoreLog.event(.productIsPurchasedFromCache, productId: productId) + return true + } + } + StoreLog.event(.productIsNotPurchasedNoEntitlement, productId: productId) return false } @@ -308,7 +318,7 @@ public class StoreHelper: ObservableObject { // See if the transaction passed StoreKit's automatic verification let result = checkVerificationResult(result: currentEntitlement) if !result.verified { - StoreLog.transaction(.transactionValidationFailure, productId: result.transaction.productID) + StoreLog.transaction(.transactionValidationFailure, productId: result.transaction.productID, transactionId: String(result.transaction.id)) throw StoreException.transactionVerificationFailed } @@ -320,7 +330,7 @@ public class StoreHelper: ObservableObject { default: throw StoreException.productTypeNotSupported } - StoreLog.event(purchased ? .productIsPurchasedFromTransaction : .productIsNotPurchased, productId: productId) + StoreLog.event(purchased ? .productIsPurchasedFromTransaction : .productIsNotPurchased, productId: productId, transactionId: String(result.transaction.id)) updatePurchasedProducts(for: productId, purchased: purchased) return purchased } @@ -453,7 +463,7 @@ public class StoreHelper: ObservableObject { let checkResult = checkVerificationResult(result: verificationResult) if !checkResult.verified { purchaseState = .failedVerification - StoreLog.transaction(.transactionValidationFailure, productId: checkResult.transaction.productID) + StoreLog.transaction(.transactionValidationFailure, productId: checkResult.transaction.productID, transactionId: String(checkResult.transaction.id)) throw StoreException.transactionVerificationFailed } @@ -471,7 +481,7 @@ public class StoreHelper: ObservableObject { // Let the caller know the purchase succeeded and that the user should be given access to the product purchaseState = .purchased - StoreLog.event(.purchaseSuccess, productId: product.id) + StoreLog.event(.purchaseSuccess, productId: product.id, transactionId: String(validatedTransaction.id)) return (transaction: validatedTransaction, purchaseState: .purchased) @@ -495,10 +505,10 @@ public class StoreHelper: ObservableObject { /// Should be called only when a purchase is handled by the StoreKit1-based AppHelper. /// This will be as a result of a user dirctly purchasing in IAP in the App Store ("IAP Promotion"), rather than in our app. /// - Parameter product: The ProductId of the purchased product. - @MainActor public func productPurchased(_ productId: ProductId) { + @MainActor public func productPurchased(_ productId: ProductId, transactionId: String) { updatePurchasedProducts(for: productId, purchased: true) purchaseState = .purchased - StoreLog.event(.purchaseSuccess, productId: productId) + StoreLog.event(.purchaseSuccess, productId: productId, transactionId: transactionId) } /// The `Product` associated with a `ProductId`. @@ -671,6 +681,9 @@ public class StoreHelper: ObservableObject { // because the they don't purchase anything and are not considered to be part of the app that did the purchasing as far as // StoreKit is concerned. AppGroupSupport.syncPurchase(productId: productId, purchased: purchased) + + // Persist the fallback list of purchased products + savePurchasedProductsFallbackList() } /// Updates and persists our fallback cache of purchased products (`purchasedProductsFallback`). Also makes sure our set of purchase @@ -706,38 +719,38 @@ public class StoreHelper: ObservableObject { let checkResult = await self.checkVerificationResult(result: verificationResult) guard checkResult.verified else { // StoreKit's attempts to validate the transaction failed - StoreLog.transaction(.transactionFailure, productId: checkResult.transaction.productID) + StoreLog.transaction(.transactionFailure, productId: checkResult.transaction.productID, transactionId: String(checkResult.transaction.id)) return } // The transaction was validated by StoreKit let transaction = checkResult.transaction - StoreLog.transaction(.transactionReceived, productId: transaction.productID) + StoreLog.transaction(.transactionReceived, productId: transaction.productID, transactionId: String(transaction.id)) if transaction.revocationDate != nil { // The user's access to the product has been revoked by the App Store (e.g. a refund, etc.) // See transaction.revocationReason for more details if required - StoreLog.transaction(.transactionRevoked, productId: transaction.productID) + StoreLog.transaction(.transactionRevoked, productId: transaction.productID, transactionId: String(transaction.id)) await self.updatePurchasedProducts(for: transaction.productID, purchased: false) return } if let expirationDate = transaction.expirationDate, expirationDate < Date() { // The user's subscription has expired - StoreLog.transaction(.transactionExpired, productId: transaction.productID) + StoreLog.transaction(.transactionExpired, productId: transaction.productID, transactionId: String(transaction.id)) await self.updatePurchasedProducts(for: transaction.productID, purchased: false) return } if transaction.isUpgraded { // Transaction superceeded by an active, higher-value subscription - StoreLog.transaction(.transactionUpgraded, productId: transaction.productID) - await self.updatePurchasedProducts(for: transaction.productID, purchased: false) + StoreLog.transaction(.transactionUpgraded, productId: transaction.productID, transactionId: String(transaction.id)) + await self.updatePurchasedProducts(for: transaction.productID, purchased: true) return } // Update the list of products the user has access to - StoreLog.transaction(.transactionSuccess, productId: transaction.productID) + StoreLog.transaction(.transactionSuccess, productId: transaction.productID, transactionId: String(transaction.id)) await self.updatePurchasedProducts(transaction: transaction, purchased: true) await transaction.finish() } diff --git a/Sources/StoreHelper/Core/StoreLog.swift b/Sources/StoreHelper/Core/StoreLog.swift index 42d484f4a..1a0ff44d3 100644 --- a/Sources/StoreHelper/Core/StoreLog.swift +++ b/Sources/StoreHelper/Core/StoreLog.swift @@ -41,7 +41,9 @@ public struct StoreLog { /// - Parameters: /// - event: A StoreNotification. /// - productId: A ProductId associated with the event. - public static func event(_ event: StoreNotification, productId: ProductId) { logEvent(event, productId: productId) } + public static func event(_ event: StoreNotification, productId: ProductId, transactionId: String? = nil) { + logEvent(event, productId: productId, transactionId: transactionId) + } /// Logs an StoreNotification. Note that the text (shortDescription) and the productId for the /// log entry will be publically available in the Console app. @@ -49,23 +51,25 @@ public struct StoreLog { /// - event: A StoreNotification. /// - productId: A ProductId associated with the event. /// - webOrderLineItemId: A unique ID that identifies subscription purchase events across devices, including subscription renewals - public static func event(_ event: StoreNotification, productId: ProductId, webOrderLineItemId: String?) { logEvent(event, productId: productId, webOrderLineItemId: webOrderLineItemId) } + public static func event(_ event: StoreNotification, productId: ProductId, webOrderLineItemId: String?, transactionId: String? = nil) { + logEvent(event, productId: productId, webOrderLineItemId: webOrderLineItemId, transactionId: transactionId) + } public static var transactionLog: Set = [] - /// Logs a StoreNotification as a transaction. Multiple transactions for the same event and product id will only be logged once. + /// Logs a StoreNotification as a transaction. Multiple transactions for the same event, product id and transaction id will only be logged once. /// Note that the text (shortDescription) and the productId for the log entry will be publically available in the Console app. /// - Parameters: /// - event: A StoreNotification. /// - productId: A ProductId associated with the event. - public static func transaction(_ event: StoreNotification, productId: ProductId) { + public static func transaction(_ event: StoreNotification, productId: ProductId, transactionId: String? = nil) { let t = TransactionLog(notification: event, productId: productId) if transactionLog.contains(t) { return } transactionLog.insert(t) #if DEBUG - print("\(event.shortDescription()) for product \(productId)") + print("\(event.shortDescription()) for product \(productId) \(transactionId == nil ? "" : "with transaction id \(transactionId!)")") #else os_log("%{public}s for product %{public}s", log: storeLog, type: .default, event.shortDescription(), productId) #endif @@ -76,9 +80,9 @@ public struct StoreLog { /// - Parameters: /// - exception: A StoreException. /// - productId: A ProductId associated with the event. - public static func exception(_ exception: StoreException, productId: ProductId) { + public static func exception(_ exception: StoreException, productId: ProductId, transactionId: String? = nil) { #if DEBUG - print("\(exception.shortDescription()). For product \(productId)") + print("\(exception.shortDescription()). For product \(productId) \(transactionId == nil ? "" : "with transaction id \(transactionId!)")") #else os_log("%{public}s for product %{public}s", log: storeLog, type: .default, exception.shortDescription(), productId) #endif @@ -106,11 +110,11 @@ public struct StoreLog { #endif } - private static func logEvent(_ event: StoreNotification, productId: ProductId) { + private static func logEvent(_ event: StoreNotification, productId: ProductId, transactionId: String? = nil) { #if DEBUG var doLog = true if event.isNotificationPurchaseState(), !logIsPurchasedEvents { doLog = false } - if doLog { print("\(event.shortDescription()) for product \(productId)") } + if doLog { print("\(event.shortDescription()) for product \(productId) \(transactionId == nil ? "" : "with transaction id \(transactionId!)")") } #else var doLog = true if event.isNotificationPurchaseState(), !logIsPurchasedEvents { doLog = false } @@ -118,11 +122,11 @@ public struct StoreLog { #endif } - private static func logEvent(_ event: StoreNotification, productId: ProductId, webOrderLineItemId: String?) { + private static func logEvent(_ event: StoreNotification, productId: ProductId, webOrderLineItemId: String?, transactionId: String? = nil) { #if DEBUG var doLog = true if event.isNotificationPurchaseState(), !logIsPurchasedEvents { doLog = false } - if doLog { print("\(event.shortDescription()) for product \(productId) with webOrderLineItemId \(webOrderLineItemId ?? "none")") } + if doLog { print("\(event.shortDescription()) for product \(productId) with webOrderLineItemId \(webOrderLineItemId ?? "none") \(transactionId == nil ? "" : "and transaction id \(transactionId!)")") } #else var doLog = true if event.isNotificationPurchaseState(), !logIsPurchasedEvents { doLog = false }