diff --git a/Sources/SwiftyStoreKit/CodeRedemptionControlller.swift b/Sources/SwiftyStoreKit/CodeRedemptionControlller.swift new file mode 100644 index 0000000..0387d81 --- /dev/null +++ b/Sources/SwiftyStoreKit/CodeRedemptionControlller.swift @@ -0,0 +1,79 @@ +import Foundation +import StoreKit + +struct CodeRedemption: Hashable { + + let atomically: Bool + let callback: (TransactionResult) -> Void + + func hash(into hasher: inout Hasher) { + hasher.combine(atomically) + } + + static func == (lhs: CodeRedemption, rhs: CodeRedemption) -> Bool { + return true + } +} + +class CodeRedemptionController: TransactionController { + + private var codeRedemption: CodeRedemption? + + func set(_ codeRedemption: CodeRedemption) { + self.codeRedemption = codeRedemption + } + + func clearCodeRedemption() { + self.codeRedemption = nil + } + + func processTransaction(_ transaction: SKPaymentTransaction, on paymentQueue: PaymentQueue) -> Bool { + + let transactionProductIdentifier = transaction.payment.productIdentifier + + guard let codeRedemption = self.codeRedemption else { + + return false + } + + let transactionState = transaction.transactionState + + if transactionState == .purchased { + let purchase = PurchaseCodeRedemptionDetails(productId: transactionProductIdentifier, quantity: transaction.payment.quantity, transaction: transaction, originalTransaction: transaction.original, needsFinishTransaction: !codeRedemption.atomically) + + codeRedemption.callback(.redeemed(purchase: purchase)) + + if codeRedemption.atomically { + paymentQueue.finishTransaction(transaction) + } + + self.clearCodeRedemption() + return true + } + + if transactionState == .failed { + + codeRedemption.callback(.failed(error: transactionError(for: transaction.error as NSError?))) + + paymentQueue.finishTransaction(transaction) + + self.clearCodeRedemption() + return true + } + + self.clearCodeRedemption() + return false + } + + func transactionError(for error: NSError?) -> SKError { + let message = "Unknown error" + let altError = NSError(domain: SKErrorDomain, code: SKError.unknown.rawValue, userInfo: [ NSLocalizedDescriptionKey: message ]) + let nsError = error ?? altError + return SKError(_nsError: nsError) + } + + func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + + return transactions.filter { !processTransaction($0, on: paymentQueue) } + } +} diff --git a/Sources/SwiftyStoreKit/PaymentQueueController.swift b/Sources/SwiftyStoreKit/PaymentQueueController.swift index 98f3c7d..2baa8bf 100644 --- a/Sources/SwiftyStoreKit/PaymentQueueController.swift +++ b/Sources/SwiftyStoreKit/PaymentQueueController.swift @@ -37,6 +37,7 @@ protocol TransactionController { public enum TransactionResult { case purchased(purchase: PurchaseDetails) + case redeemed(purchase: PurchaseCodeRedemptionDetails) case restored(purchase: Purchase) case deferred(purchase: PurchaseDetails) case failed(error: SKError) @@ -57,6 +58,9 @@ public protocol PaymentQueue: AnyObject { func restoreCompletedTransactions(withApplicationUsername username: String?) func finishTransaction(_ transaction: SKPaymentTransaction) + + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() } extension SKPaymentQueue: PaymentQueue { @@ -103,6 +107,7 @@ struct EntitlementRevocation { class PaymentQueueController: NSObject, SKPaymentTransactionObserver { private let paymentsController: PaymentsController + private let codeRedemptionController: CodeRedemptionController private let restorePurchasesController: RestorePurchasesController private let completeTransactionsController: CompleteTransactionsController unowned let paymentQueue: PaymentQueue @@ -114,11 +119,13 @@ class PaymentQueueController: NSObject, SKPaymentTransactionObserver { init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), paymentsController: PaymentsController = PaymentsController(), + codeRedemptionController: CodeRedemptionController = CodeRedemptionController(), restorePurchasesController: RestorePurchasesController = RestorePurchasesController(), completeTransactionsController: CompleteTransactionsController = CompleteTransactionsController()) { self.paymentQueue = paymentQueue self.paymentsController = paymentsController + self.codeRedemptionController = codeRedemptionController self.restorePurchasesController = restorePurchasesController self.completeTransactionsController = completeTransactionsController super.init() @@ -131,7 +138,15 @@ class PaymentQueueController: NSObject, SKPaymentTransactionObserver { assert(completeTransactionsController.completeTransactions != nil, message) } + /** + This method is used to clean up the callback of the controller that handles the offer code redemptions (CodeRedemptionController) each time a normal purchase or a purchase restore is to be made. As the code redemption is managed by Apple and we do not find out if the Apple modal that we use to redeem the code (presentCodeRedemptionSheet ()) is closed, we have to delete the callback assigned previously so that there is no crossing of flows in SKPaymentTransactionObserver, thus, the controller in charge of managing the code exchanges will not manage any transaction. + */ + private func clearCompletionCodeRedemptionController() { + codeRedemptionController.clearCodeRedemption() + } + func startPayment(_ payment: Payment) { + clearCompletionCodeRedemptionController() assertCompleteTransactionsWasCalled() let skPayment = SKMutablePayment(product: payment.product) @@ -165,6 +180,7 @@ class PaymentQueueController: NSObject, SKPaymentTransactionObserver { } func restorePurchases(_ restorePurchases: RestorePurchases) { + clearCompletionCodeRedemptionController() assertCompleteTransactionsWasCalled() if restorePurchasesController.restorePurchases != nil { @@ -193,6 +209,15 @@ class PaymentQueueController: NSObject, SKPaymentTransactionObserver { paymentQueue.finishTransaction(skTransaction) } + @available(iOS 14.0, *) + func presentCodeRedemptionSheet(_ codeRedemption: CodeRedemption) { + assertCompleteTransactionsWasCalled() + + codeRedemptionController.set(codeRedemption) + + paymentQueue.presentCodeRedemptionSheet() + } + func start(_ downloads: [SKDownload]) { paymentQueue.start(downloads) } @@ -243,6 +268,8 @@ class PaymentQueueController: NSObject, SKPaymentTransactionObserver { unhandledTransactions = paymentsController.processTransactions(transactions, on: paymentQueue) + unhandledTransactions = codeRedemptionController.processTransactions(unhandledTransactions, on: paymentQueue) + unhandledTransactions = restorePurchasesController.processTransactions(unhandledTransactions, on: paymentQueue) unhandledTransactions = completeTransactionsController.processTransactions(unhandledTransactions, on: paymentQueue) diff --git a/Sources/SwiftyStoreKit/SwiftyStoreKit+Types.swift b/Sources/SwiftyStoreKit/SwiftyStoreKit+Types.swift index 53a9ae3..a5c5b4d 100644 --- a/Sources/SwiftyStoreKit/SwiftyStoreKit+Types.swift +++ b/Sources/SwiftyStoreKit/SwiftyStoreKit+Types.swift @@ -96,6 +96,23 @@ public struct PurchaseDetails { } } +/// Purchased product with offer code +public struct PurchaseCodeRedemptionDetails { + public let productId: String + public let quantity: Int + public let transaction: PaymentTransaction + public let originalTransaction: PaymentTransaction? + public let needsFinishTransaction: Bool + + public init(productId: String, quantity: Int, transaction: PaymentTransaction, originalTransaction: PaymentTransaction?, needsFinishTransaction: Bool) { + self.productId = productId + self.quantity = quantity + self.transaction = transaction + self.originalTransaction = originalTransaction + self.needsFinishTransaction = needsFinishTransaction + } +} + /// Conform to this protocol to provide custom receipt validator public protocol ReceiptValidator { func validate(receiptData: Data, completion: @escaping (VerifyReceiptResult) -> Void) @@ -132,6 +149,13 @@ public enum PurchaseResult { case error(error: SKError) } +/// CodeRedemption result +public enum CodeRedemptionResult { + case redeemed(purchase: PurchaseCodeRedemptionDetails) + case deferred(purchase: PurchaseCodeRedemptionDetails) + case error(error: SKError) +} + /// Restore purchase results public struct RestoreResults { public let restoredPurchases: [Purchase] diff --git a/Sources/SwiftyStoreKit/SwiftyStoreKit.swift b/Sources/SwiftyStoreKit/SwiftyStoreKit.swift index 8ce9093..f4e74c6 100644 --- a/Sources/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/Sources/SwiftyStoreKit/SwiftyStoreKit.swift @@ -85,6 +85,17 @@ public class SwiftyStoreKit { paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion)) } + + fileprivate func redeemOfferCode(atomically: Bool, completion: @escaping (CodeRedemptionResult) -> Void) { + + if #available(iOS 14.0, *) { + paymentQueueController.presentCodeRedemptionSheet(CodeRedemption(atomically: atomically) { result in + + completion(self.processCodeRedemptionResult(result)) + }) + } + } + fileprivate func onEntitlementRevocation(completion: @escaping ([String]) -> Void) { paymentQueueController.onEntitlementRevocation(EntitlementRevocation(callback: completion)) @@ -101,6 +112,8 @@ public class SwiftyStoreKit { return .success(purchase: purchase) case .deferred(let purchase): return .deferred(purchase: purchase) + case .redeemed(purchase: let purchase): + return .error(error: storeInternalError(description: "Cannot redeemed code product \(purchase.productId) from purchase path")) case .failed(let error): return .error(error: error) case .restored(let purchase): @@ -108,6 +121,22 @@ public class SwiftyStoreKit { } } + private func processCodeRedemptionResult(_ result: TransactionResult) -> CodeRedemptionResult { + switch result { + case .purchased(purchase: let purchase): + return .error(error: storeInternalError(description: "Cannot purchase product \(purchase.productId) from code redemption path")) + case .redeemed(let purchase): + return .redeemed(purchase: purchase) + case .deferred(let purchase): + let error = storeInternalError(description: "Cannot purchase product \(purchase.productId) from restore purchases path") + return .error(error: error) + case .failed(let error): + return .error(error: error) + case .restored(let purchase): + return .error(error: storeInternalError(description: "Cannot restore product \(purchase.productId) from code redemption path")) + } + } + private func processRestoreResults(_ results: [TransactionResult]) -> RestoreResults { var restoredPurchases: [Purchase] = [] var restoreFailedPurchases: [(SKError, String?)] = [] @@ -116,6 +145,9 @@ public class SwiftyStoreKit { case .purchased(let purchase): let error = storeInternalError(description: "Cannot purchase product \(purchase.productId) from restore purchases path") restoreFailedPurchases.append((error, purchase.productId)) + case .redeemed(purchase: let purchase): + let error = storeInternalError(description: "Cannot redeem code product \(purchase.productId) from restore purchases path") + restoreFailedPurchases.append((error, purchase.productId)) case .deferred(let purchase): let error = storeInternalError(description: "Cannot purchase product \(purchase.productId) from restore purchases path") restoreFailedPurchases.append((error, purchase.productId)) @@ -190,6 +222,14 @@ extension SwiftyStoreKit { sharedInstance.restorePurchases(atomically: atomically, applicationUsername: applicationUsername, completion: completion) } + /// RedeemOfferCode + /// - Parameter atomically: whether the code is redeemed atomically (e.g. `finishTransaction` is called immediately) + public class func redeemOfferCode(atomically: Bool = true, completion: @escaping (CodeRedemptionResult) -> Void) { + + sharedInstance.redeemOfferCode(atomically: atomically, completion: completion) + + } + /// Complete transactions /// - Parameter atomically: whether the product is purchased atomically (e.g. `finishTransaction` is called immediately) /// - Parameter completion: handler for result diff --git a/Tests/SwiftyStoreKitTests/PaymentQueueSpy.swift b/Tests/SwiftyStoreKitTests/PaymentQueueSpy.swift index f763989..a4b336a 100644 --- a/Tests/SwiftyStoreKitTests/PaymentQueueSpy.swift +++ b/Tests/SwiftyStoreKitTests/PaymentQueueSpy.swift @@ -10,6 +10,8 @@ import SwiftyStoreKit import StoreKit class PaymentQueueSpy: PaymentQueue { + + weak var observer: SKPaymentTransactionObserver? @@ -45,6 +47,10 @@ class PaymentQueueSpy: PaymentQueue { finishTransactionCalledCount += 1 } + func presentCodeRedemptionSheet() { + + } + func start(_ downloads: [SKDownload]) { } @@ -60,4 +66,5 @@ class PaymentQueueSpy: PaymentQueue { func cancel(_ downloads: [SKDownload]) { } + }