From 60b06c0ba84b03d7c1d5059739aea859df2784e2 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 16 May 2017 00:41:38 +0200 Subject: [PATCH 1/8] Move receipt refresh and verification code to InAppReceiptVerificator. Removed refreshReceipt method from SwiftyStoreKit.swift as this is now performed internally --- README.md | 30 +++--- SwiftyStoreKit-iOS-Demo/ViewController.swift | 55 ++++------- .../ViewController.swift | 54 ++++------- SwiftyStoreKit.xcodeproj/project.pbxproj | 13 ++- SwiftyStoreKit/InAppReceipt.swift | 37 -------- SwiftyStoreKit/InAppReceiptVerificator.swift | 94 +++++++++++++++++++ SwiftyStoreKit/SwiftyStoreKit+Types.swift | 2 +- SwiftyStoreKit/SwiftyStoreKit.swift | 61 +++--------- 8 files changed, 164 insertions(+), 182 deletions(-) create mode 100644 SwiftyStoreKit/InAppReceiptVerificator.swift diff --git a/README.md b/README.md index 6e7e4a72..a10753e9 100644 --- a/README.md +++ b/README.md @@ -214,31 +214,23 @@ let receiptString = receiptData.base64EncodedString(options: []) ```swift let appleValidator = AppleReceiptValidator(service: .production) -SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in - if case .error(let error) = result { - if case .noReceiptData = error { - self.refreshReceipt() - } - } -} - -func refreshReceipt() { - SwiftyStoreKit.refreshReceipt { result in - switch result { - case .success(let receiptData): - print("Receipt refresh success: \(receiptData.base64EncodedString)") - case .error(let error): - print("Receipt refresh failed: \(error)") - } - } +let password = "your-shared-secret" +SwiftyStoreKit.verifyReceipt(using: appleValidator, password: password) { result in + switch result { + case .success(let receipt): + print("Verify receipt Success: \(receipt)") + case .error(let error): + print("Verify receipt Failed: \(error)") + } } ``` #### Notes -* If the user is not logged to iTunes when `refreshReceipt` is called, StoreKit will present a popup asking to **Sign In to the iTunes Store**. -* If the user enters valid credentials, the receipt will be refreshed. +* If the user is not logged to iTunes when `verifyReceipt` is called, StoreKit will present a popup asking to **Sign In to the iTunes Store**. +* If the user enters valid credentials, the receipt will be refreshed and verified. * If the user cancels, receipt refresh will fail with a **Cannot connect to iTunes Store** error. +* The receipt is only refreshed if it's not already stored in `Bundle.main.appStoreReceiptURL`. ### Verify Purchase diff --git a/SwiftyStoreKit-iOS-Demo/ViewController.swift b/SwiftyStoreKit-iOS-Demo/ViewController.swift index a6b41689..accd7299 100644 --- a/SwiftyStoreKit-iOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-iOS-Demo/ViewController.swift @@ -62,7 +62,7 @@ class ViewController: UIViewController { @IBAction func verifyPurchase2() { verifyPurchase(purchase2Suffix) } - + func getInfo(_ purchase: RegisteredPurchase) { NetworkActivityIndicatorManager.networkOperationStarted() @@ -108,25 +108,23 @@ class ViewController: UIViewController { @IBAction func verifyReceipt() { NetworkActivityIndicatorManager.networkOperationStarted() - let appleValidator = AppleReceiptValidator(service: .production) - SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in + verifyReceipt { result in NetworkActivityIndicatorManager.networkOperationFinished() - self.showAlert(self.alertForVerifyReceipt(result)) - - if case .error(let error) = result { - if case .noReceiptData = error { - self.refreshReceipt() - } - } } } + + func verifyReceipt(completion: @escaping (VerifyReceiptResult) -> Void) { + + let appleValidator = AppleReceiptValidator(service: .production) + let password = "your-shared-secret" + SwiftyStoreKit.verifyReceipt(using: appleValidator, password: password, completion: completion) + } func verifyPurchase(_ purchase: RegisteredPurchase) { NetworkActivityIndicatorManager.networkOperationStarted() - let appleValidator = AppleReceiptValidator(service: .production) - SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in + verifyReceipt { result in NetworkActivityIndicatorManager.networkOperationFinished() switch result { @@ -159,23 +157,12 @@ class ViewController: UIViewController { self.showAlert(self.alertForVerifyPurchase(purchaseResult)) } - case .error(let error): + case .error: self.showAlert(self.alertForVerifyReceipt(result)) - if case .noReceiptData = error { - self.refreshReceipt() - } } } } - func refreshReceipt() { - - SwiftyStoreKit.refreshReceipt { result in - - self.showAlert(self.alertForRefreshReceipt(result)) - } - } - #if os(iOS) override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent @@ -262,14 +249,16 @@ extension ViewController { switch result { case .success(let receipt): print("Verify receipt Success: \(receipt)") - return alertWithTitle("Receipt verified", message: "Receipt verified remotly") + return alertWithTitle("Receipt verified", message: "Receipt verified remotely") case .error(let error): print("Verify receipt Failed: \(error)") switch error { - case .noReceiptData : + case .noReceiptData: return alertWithTitle("Receipt verification", message: "No receipt data, application will try to get a new one. Try again.") + case .networkError(let error): + return alertWithTitle("Receipt verification", message: "Network error while verifying receipt: \(error)") default: - return alertWithTitle("Receipt verification", message: "Receipt verification failed") + return alertWithTitle("Receipt verification", message: "Receipt verification failed: \(error)") } } } @@ -300,16 +289,4 @@ extension ViewController { return alertWithTitle("Not purchased", message: "This product has never been purchased") } } - - func alertForRefreshReceipt(_ result: RefreshReceiptResult) -> UIAlertController { - switch result { - case .success(let receiptData): - print("Receipt refresh Success: \(receiptData.base64EncodedString)") - return alertWithTitle("Receipt refreshed", message: "Receipt refreshed successfully") - case .error(let error): - print("Receipt refresh Failed: \(error)") - return alertWithTitle("Receipt refresh failed", message: "Receipt refresh failed") - } - } - } diff --git a/SwiftyStoreKit-macOS-Demo/ViewController.swift b/SwiftyStoreKit-macOS-Demo/ViewController.swift index d2b838fe..fa6f8276 100644 --- a/SwiftyStoreKit-macOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-macOS-Demo/ViewController.swift @@ -105,24 +105,22 @@ class ViewController: NSViewController { @IBAction func verifyReceipt(_ sender: Any?) { - let appleValidator = AppleReceiptValidator(service: .production) - SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in - - self.showAlert(self.alertForVerifyReceipt(result)) { _ in - - if case .error(let error) = result { - if case .noReceiptData = error { - self.refreshReceipt() - } - } - } + verifyReceipt { result in + self.showAlert(self.alertForVerifyReceipt(result)) } } + + func verifyReceipt(completion: @escaping (VerifyReceiptResult) -> Void) { + + let appleValidator = AppleReceiptValidator(service: .production) + let password = "your-shared-secret" + SwiftyStoreKit.verifyReceipt(using: appleValidator, password: password, completion: completion) + } func verifyPurchase(_ purchase: RegisteredPurchase) { let appleValidator = AppleReceiptValidator(service: .production) - SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in + verifyReceipt { result in switch result { case .success(let receipt): @@ -157,15 +155,6 @@ class ViewController: NSViewController { } } } - - func refreshReceipt() { - - SwiftyStoreKit.refreshReceipt { result in - - self.showAlert(self.alertForRefreshReceipt(result)) - } - } - } // MARK: User facing alerts @@ -244,10 +233,17 @@ extension ViewController { switch result { case .success(let receipt): print("Verify receipt Success: \(receipt)") - return self.alertWithTitle("Receipt verified", message: "Receipt verified remotly") + return self.alertWithTitle("Receipt verified", message: "Receipt verified remotely") case .error(let error): print("Verify receipt Failed: \(error)") - return self.alertWithTitle("Receipt verification failed", message: "The application will exit to create receipt data. You must have signed the application with your developer id to test and be outside of XCode") + switch error { + case .noReceiptData: + return alertWithTitle("Receipt verification", message: "No receipt data, application will try to get a new one. Try again.") + case .networkError(let error): + return alertWithTitle("Receipt verification", message: "Network error while verifying receipt: \(error)") + default: + return alertWithTitle("Receipt verification", message: "Receipt verification failed: \(error)") + } } } @@ -277,16 +273,4 @@ extension ViewController { return alertWithTitle("Not purchased", message: "This product has never been purchased") } } - - func alertForRefreshReceipt(_ result: RefreshReceiptResult) -> NSAlert { - switch result { - case .success(let receiptData): - print("Receipt refresh Success: \(receiptData.base64EncodedString)") - return alertWithTitle("Receipt refreshed", message: "Receipt refreshed successfully") - case .error(let error): - print("Receipt refresh Failed: \(error)") - return alertWithTitle("Receipt refresh failed", message: "Receipt refresh failed") - } - } - } diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index f3728937..d95f6437 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -47,6 +47,9 @@ 65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; 65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; 65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; + 65E9E0791ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; + 65E9E07A1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; + 65E9E07B1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */; }; 65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; }; 65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; }; @@ -179,6 +182,7 @@ 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = ""; }; 65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptTests.swift; sourceTree = ""; }; 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = ""; }; + 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificator.swift; sourceTree = ""; }; 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = ""; }; 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentsController.swift; sourceTree = ""; }; 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -306,6 +310,7 @@ 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */, C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */, C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */, + 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */, 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */, 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */, C40C680F1C29414C00B60B7E /* OS.swift */, @@ -558,6 +563,7 @@ }; 6502F5FD1B985833004E342D = { CreatedOnToolsVersion = 7.0; + DevelopmentTeam = M54ZVB688G; LastSwiftMigration = 0800; ProvisioningStyle = Automatic; }; @@ -710,6 +716,7 @@ 650307FE1E33154F001332A4 /* ProductsInfoController.swift in Sources */, 650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, + 65E9E07B1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */, 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, 54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */, 650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */, @@ -740,6 +747,7 @@ 650307FC1E33154F001332A4 /* ProductsInfoController.swift in Sources */, 650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, + 65E9E0791ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */, 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */, 650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */, @@ -786,6 +794,7 @@ 650307FD1E33154F001332A4 /* ProductsInfoController.swift in Sources */, 650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, + 65E9E07A1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */, 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */, 650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */, @@ -1015,7 +1024,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = M54ZVB688G; INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -1031,7 +1040,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = M54ZVB688G; INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; diff --git a/SwiftyStoreKit/InAppReceipt.swift b/SwiftyStoreKit/InAppReceipt.swift index 25508156..0b4b51ec 100644 --- a/SwiftyStoreKit/InAppReceipt.swift +++ b/SwiftyStoreKit/InAppReceipt.swift @@ -80,43 +80,6 @@ extension ReceiptItem { // MARK - receipt mangement internal class InAppReceipt { - static var appStoreReceiptUrl: URL? { - return Bundle.main.appStoreReceiptURL - } - - static var appStoreReceiptData: Data? { - guard let receiptDataURL = appStoreReceiptUrl, let data = try? Data(contentsOf: receiptDataURL) else { - return nil - } - return data - } - - // The base64 encoded receipt data. - static var appStoreReceiptBase64Encoded: String? { - return appStoreReceiptData?.base64EncodedString(options: []) - } - - // https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html - - /** - * - Parameter receiptVerifyURL: receipt verify url (default: Production) - * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). - * - Parameter session: the session used to make remote call. - * - Parameter completion: handler for result - */ - class func verify(using validator: ReceiptValidator, - password autoRenewPassword: String? = nil, - completion: @escaping (VerifyReceiptResult) -> Void) { - - // If no receipt is present, validation fails. - guard let base64EncodedString = appStoreReceiptBase64Encoded else { - completion(.error(error: .noReceiptData)) - return - } - - validator.validate(receipt: base64EncodedString, password: autoRenewPassword, completion: completion) - } - /** * Verify the purchase of a Consumable or NonConsumable product in a receipt * - Parameter productId: the product id of the purchase to verify diff --git a/SwiftyStoreKit/InAppReceiptVerificator.swift b/SwiftyStoreKit/InAppReceiptVerificator.swift new file mode 100644 index 00000000..23d52158 --- /dev/null +++ b/SwiftyStoreKit/InAppReceiptVerificator.swift @@ -0,0 +1,94 @@ +// +// InAppReceiptVerificator.swift +// SwiftyStoreKit +// +// Created by Andrea Bizzotto on 16/05/2017. +// Copyright (c) 2017 Andrea Bizzotto (bizz84@gmail.com) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation + +class InAppReceiptVerificator: NSObject { + + static var appStoreReceiptUrl: URL? { + return Bundle.main.appStoreReceiptURL + } + + static var appStoreReceiptData: Data? { + guard let receiptDataURL = appStoreReceiptUrl, let data = try? Data(contentsOf: receiptDataURL) else { + return nil + } + return data + } + + private var receiptRefreshRequest: InAppReceiptRefreshRequest? + + /** + * Verify application receipt + * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). + * - Parameter session: the session used to make remote call. + * - Parameter completion: handler for result + */ + public func verifyReceipt(using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) { + + if let receiptData = InAppReceiptVerificator.appStoreReceiptData { + + verify(receiptData: receiptData, using: validator, password: password, completion: completion) + } else { + + receiptRefreshRequest = InAppReceiptRefreshRequest.refresh { result in + + self.receiptRefreshRequest = nil + + switch result { + case .success: + if let receiptData = InAppReceiptVerificator.appStoreReceiptData { + self.verify(receiptData: receiptData, using: validator, password: password, completion: completion) + } else { + completion(.error(error: .noReceiptData)) + } + case .error(let e): + completion(.error(error: .networkError(error: e))) + } + } + } + } + + // https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html + + /** + * - Parameter receiptData: encrypted receipt data + * - Parameter validator: the validator to use + * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). + * - Parameter completion: handler for result + */ + private func verify(receiptData: Data, using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) { + + // The base64 encoded receipt data. + let base64EncodedString = receiptData.base64EncodedString(options: []) + + validator.validate(receipt: base64EncodedString, password: password) { result in + + DispatchQueue.main.async { + completion(result) + } + } + } +} diff --git a/SwiftyStoreKit/SwiftyStoreKit+Types.swift b/SwiftyStoreKit/SwiftyStoreKit+Types.swift index 753f0bda..b41d6160 100644 --- a/SwiftyStoreKit/SwiftyStoreKit+Types.swift +++ b/SwiftyStoreKit/SwiftyStoreKit+Types.swift @@ -138,7 +138,7 @@ public struct ReceiptItem { public enum ReceiptError: Swift.Error { // No receipt data case noReceiptData - // No data receice + // No data received case noRemoteData // Error when encoding HTTP body into JSON case requestBodyEncodeError(error: Swift.Error) diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index 7471618f..ef0a3b5d 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -30,13 +30,14 @@ public class SwiftyStoreKit { private let paymentQueueController: PaymentQueueController - private var receiptRefreshRequest: InAppReceiptRefreshRequest? + fileprivate let receiptVerificator: InAppReceiptVerificator init(productsInfoController: ProductsInfoController = ProductsInfoController(), paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default())) { self.productsInfoController = productsInfoController self.paymentQueueController = paymentQueueController + self.receiptVerificator = InAppReceiptVerificator() } // MARK: Internal methods @@ -83,24 +84,6 @@ public class SwiftyStoreKit { paymentQueueController.finishTransaction(transaction) } - func refreshReceipt(_ receiptProperties: [String : Any]? = nil, completion: @escaping (RefreshReceiptResult) -> Void) { - receiptRefreshRequest = InAppReceiptRefreshRequest.refresh(receiptProperties) { result in - - self.receiptRefreshRequest = nil - - switch result { - case .success: - if let appStoreReceiptData = InAppReceipt.appStoreReceiptData { - completion(.success(receiptData: appStoreReceiptData)) - } else { - completion(.error(error: ReceiptError.noReceiptData)) - } - case .error(let e): - completion(.error(error: e)) - } - } - } - // MARK: private methods private func purchase(product: SKProduct, quantity: Int, atomically: Bool, applicationUsername: String = "", completion: @escaping (PurchaseResult) -> Void) { guard SwiftyStoreKit.canMakePayments else { @@ -152,7 +135,7 @@ public class SwiftyStoreKit { extension SwiftyStoreKit { // MARK: Singleton - private static let sharedInstance = SwiftyStoreKit() + fileprivate static let sharedInstance = SwiftyStoreKit() // MARK: Public methods - Purchases public class var canMakePayments: Bool { @@ -191,12 +174,6 @@ extension SwiftyStoreKit { sharedInstance.finishTransaction(transaction) } - - // After verifying receive and have `ReceiptError.NoReceiptData`, refresh receipt using this method - public class func refreshReceipt(_ receiptProperties: [String : Any]? = nil, completion: @escaping (RefreshReceiptResult) -> Void) { - - sharedInstance.refreshReceipt(receiptProperties, completion: completion) - } } extension SwiftyStoreKit { @@ -207,38 +184,28 @@ extension SwiftyStoreKit { * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL */ public static var localReceiptData: Data? { - return InAppReceipt.appStoreReceiptData + return InAppReceiptVerificator.appStoreReceiptData } /** * Verify application receipt + * - Parameter validator: receipt validator to use * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). - * - Parameter session: the session used to make remote call. * - Parameter completion: handler for result */ - public class func verifyReceipt( - using validator: ReceiptValidator, - password: String? = nil, - completion:@escaping (VerifyReceiptResult) -> Void) { + public class func verifyReceipt(using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) { - InAppReceipt.verify(using: validator, password: password) { result in - - DispatchQueue.main.async { - completion(result) - } - } + sharedInstance.receiptVerificator.verifyReceipt(using: validator, password: password, completion: completion) } - + /** * Verify the purchase of a Consumable or NonConsumable product in a receipt * - Parameter productId: the product id of the purchase to verify * - Parameter inReceipt: the receipt to use for looking up the purchase * - return: either notPurchased or purchased */ - public class func verifyPurchase( - productId: String, - inReceipt receipt: ReceiptInfo - ) -> VerifyPurchaseResult { + public class func verifyPurchase(productId: String, inReceipt receipt: ReceiptInfo) -> VerifyPurchaseResult { + return InAppReceipt.verifyPurchase(productId: productId, inReceipt: receipt) } @@ -250,12 +217,8 @@ extension SwiftyStoreKit { * - Parameter validDuration: the duration of the subscription. Only required for non-renewable subscription. * - return: either NotPurchased or Purchased / Expired with the expiry date found in the receipt */ - public class func verifySubscription( - type: SubscriptionType, - productId: String, - inReceipt receipt: ReceiptInfo, - validUntil date: Date = Date() - ) -> VerifySubscriptionResult { + public class func verifySubscription(type: SubscriptionType, productId: String, inReceipt receipt: ReceiptInfo, validUntil date: Date = Date()) -> VerifySubscriptionResult { + return InAppReceipt.verifySubscription(type: type, productId: productId, inReceipt: receipt, validUntil: date) } } From aadfd8402be35631c7d2485927e07058ac202ff5 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 17 May 2017 14:59:34 +0200 Subject: [PATCH 2/8] Make InAppReceiptVerificator testable, add unit tests --- SwiftyStoreKit.xcodeproj/project.pbxproj | 9 +- .../InAppReceiptRefreshRequest.swift | 3 +- SwiftyStoreKit/InAppReceiptVerificator.swift | 25 +- SwiftyStoreKit/SwiftyStoreKit.swift | 2 +- .../InAppReceiptVerificatorTests.swift | 226 ++++++++++++++++++ 5 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 SwiftyStoreKitTests/InAppReceiptVerificatorTests.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index d95f6437..6d698c06 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; 65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; 65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; }; + 65CEF0F41ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */; }; 65E9E0791ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; 65E9E07A1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; 65E9E07B1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; }; @@ -182,6 +183,7 @@ 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = ""; }; 65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptTests.swift; sourceTree = ""; }; 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = ""; }; + 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificatorTests.swift; sourceTree = ""; }; 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificator.swift; sourceTree = ""; }; 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = ""; }; 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentsController.swift; sourceTree = ""; }; @@ -338,6 +340,7 @@ 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */, C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */, 65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */, + 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */, 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */, 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, C3099C081E2FCE3A00392A54 /* TestProduct.swift */, @@ -563,7 +566,6 @@ }; 6502F5FD1B985833004E342D = { CreatedOnToolsVersion = 7.0; - DevelopmentTeam = M54ZVB688G; LastSwiftMigration = 0800; ProvisioningStyle = Automatic; }; @@ -772,6 +774,7 @@ files = ( C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */, 650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */, + 65CEF0F41ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift in Sources */, C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */, 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */, 658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */, @@ -1024,7 +1027,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = M54ZVB688G; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -1040,7 +1043,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = M54ZVB688G; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; diff --git a/SwiftyStoreKit/InAppReceiptRefreshRequest.swift b/SwiftyStoreKit/InAppReceiptRefreshRequest.swift index 5cdc5fa8..5858fe10 100644 --- a/SwiftyStoreKit/InAppReceiptRefreshRequest.swift +++ b/SwiftyStoreKit/InAppReceiptRefreshRequest.swift @@ -34,6 +34,7 @@ class InAppReceiptRefreshRequest: NSObject, SKRequestDelegate { } typealias RequestCallback = (ResultType) -> Void + typealias ReceiptRefresh = (_ receiptProperties: [String : Any]?, _ callback: @escaping RequestCallback) -> InAppReceiptRefreshRequest class func refresh(_ receiptProperties: [String : Any]? = nil, callback: @escaping RequestCallback) -> InAppReceiptRefreshRequest { let request = InAppReceiptRefreshRequest(receiptProperties: receiptProperties, callback: callback) @@ -48,7 +49,7 @@ class InAppReceiptRefreshRequest: NSObject, SKRequestDelegate { refreshReceiptRequest.delegate = nil } - private init(receiptProperties: [String : Any]? = nil, callback: @escaping RequestCallback) { + init(receiptProperties: [String : Any]? = nil, callback: @escaping RequestCallback) { self.callback = callback self.refreshReceiptRequest = SKReceiptRefreshRequest(receiptProperties: receiptProperties) super.init() diff --git a/SwiftyStoreKit/InAppReceiptVerificator.swift b/SwiftyStoreKit/InAppReceiptVerificator.swift index 23d52158..e375014a 100644 --- a/SwiftyStoreKit/InAppReceiptVerificator.swift +++ b/SwiftyStoreKit/InAppReceiptVerificator.swift @@ -27,39 +27,44 @@ import Foundation class InAppReceiptVerificator: NSObject { - static var appStoreReceiptUrl: URL? { - return Bundle.main.appStoreReceiptURL + let appStoreReceiptURL: URL? + init(appStoreReceiptURL: URL? = Bundle.main.appStoreReceiptURL) { + self.appStoreReceiptURL = appStoreReceiptURL } - - static var appStoreReceiptData: Data? { - guard let receiptDataURL = appStoreReceiptUrl, let data = try? Data(contentsOf: receiptDataURL) else { + + var appStoreReceiptData: Data? { + guard let receiptDataURL = appStoreReceiptURL, + let data = try? Data(contentsOf: receiptDataURL) else { return nil } return data } private var receiptRefreshRequest: InAppReceiptRefreshRequest? - + /** * Verify application receipt * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). * - Parameter session: the session used to make remote call. * - Parameter completion: handler for result */ - public func verifyReceipt(using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) { + public func verifyReceipt(using validator: ReceiptValidator, + password: String? = nil, + refresh: InAppReceiptRefreshRequest.ReceiptRefresh = InAppReceiptRefreshRequest.refresh, + completion: @escaping (VerifyReceiptResult) -> Void) { - if let receiptData = InAppReceiptVerificator.appStoreReceiptData { + if let receiptData = appStoreReceiptData { verify(receiptData: receiptData, using: validator, password: password, completion: completion) } else { - receiptRefreshRequest = InAppReceiptRefreshRequest.refresh { result in + receiptRefreshRequest = refresh(nil) { result in self.receiptRefreshRequest = nil switch result { case .success: - if let receiptData = InAppReceiptVerificator.appStoreReceiptData { + if let receiptData = self.appStoreReceiptData { self.verify(receiptData: receiptData, using: validator, password: password, completion: completion) } else { completion(.error(error: .noReceiptData)) diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index ef0a3b5d..3b4413f8 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -184,7 +184,7 @@ extension SwiftyStoreKit { * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL */ public static var localReceiptData: Data? { - return InAppReceiptVerificator.appStoreReceiptData + return sharedInstance.receiptVerificator.appStoreReceiptData } /** diff --git a/SwiftyStoreKitTests/InAppReceiptVerificatorTests.swift b/SwiftyStoreKitTests/InAppReceiptVerificatorTests.swift new file mode 100644 index 00000000..5bdbb055 --- /dev/null +++ b/SwiftyStoreKitTests/InAppReceiptVerificatorTests.swift @@ -0,0 +1,226 @@ +// +// InAppReceiptVerificatorTests.swift +// SwiftyStoreKit +// +// Created by Andrea Bizzotto on 17/05/2017. +// Copyright (c) 2017 Andrea Bizzotto (bizz84@gmail.com) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import XCTest +@testable import SwiftyStoreKit + +class TestReceiptValidator: ReceiptValidator { + var validateCalled = false + func validate(receipt: String, password autoRenewPassword: String?, completion: @escaping (VerifyReceiptResult) -> Void) { + validateCalled = true + completion(.success(receipt: [:])) + } +} + +class TestInAppReceiptRefreshRequest: InAppReceiptRefreshRequest { + + override func start() { + // do nothing + } +} + +extension VerifyReceiptResult: Equatable { + + static public func == (lhs: VerifyReceiptResult, rhs: VerifyReceiptResult) -> Bool { + switch (lhs, rhs) { + case (.success(_), .success(_)): return true + case (.error(let lhsError), .error(let rhsError)): return lhsError == rhsError + default: return false + } + } +} + +extension ReceiptError: Equatable { + + static public func == (lhs: ReceiptError, rhs: ReceiptError) -> Bool { + switch (lhs, rhs) { + case (.noReceiptData, .noReceiptData): return true + case (.noRemoteData, .noRemoteData): return true + case (.requestBodyEncodeError(_), .requestBodyEncodeError(_)): return true + case (.networkError(_), .networkError(_)): return true + case (.jsonDecodeError(_), .jsonDecodeError(_)): return true + case (.receiptInvalid(_, _), .receiptInvalid(_, _)): return true + default: return false + } + } +} + +class InAppReceiptVerificatorTests: XCTestCase { + + // MARK: refresh tests (no receipt url or no receipt data) + func testVerifyReceipt_when_appStoreReceiptURLIsNil_then_callsRefresh() { + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil) + + var refreshCalled = false + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + refreshCalled = true + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { _ in + + } + XCTAssertTrue(refreshCalled) + } + + func testVerifyReceipt_when_appStoreReceiptURLIsNotNil_noReceiptData_then_callsRefresh() { + + let testReceiptURL = makeReceiptURL() + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: testReceiptURL) + + var refreshCalled = false + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + refreshCalled = true + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { _ in + + } + XCTAssertTrue(refreshCalled) + } + + func testVerifyReceipt_when_appStoreReceiptURLIsNil_refreshCallbackError_then_errorNetworkError() { + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil) + let refreshError = NSError(domain: "", code: 0, userInfo: nil) + + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + callback(.error(e: refreshError)) + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { result in + + XCTAssertEqual(result, VerifyReceiptResult.error(error: ReceiptError.networkError(error: refreshError))) + } + } + + func testVerifyReceipt_when_appStoreReceiptURLIsNil_refreshCallbackSuccess_receiptDataNotWritten_then_errorNoReceiptData_validateNotCalled() { + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil) + + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + callback(.success) + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { result in + + XCTAssertEqual(result, VerifyReceiptResult.error(error: ReceiptError.noReceiptData)) + } + XCTAssertFalse(validator.validateCalled) + } + + func testVerifyReceipt_when_appStoreReceiptURLIsNil_noReceiptData_refreshCallbackSuccess_receiptDataWritten_then_errorNoReceiptData_validateNotCalled() { + + let testReceiptURL = makeReceiptURL() + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil) + + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + writeReceiptData(to: testReceiptURL) + callback(.success) + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { result in + + XCTAssertEqual(result, VerifyReceiptResult.error(error: ReceiptError.noReceiptData)) + } + XCTAssertFalse(validator.validateCalled) + removeReceiptData(at: testReceiptURL) + } + + func testVerifyReceipt_when_appStoreReceiptURLIsNotNil_noReceiptData_refreshCallbackSuccess_receiptDataWritten_then_validateIsCalled() { + + let testReceiptURL = makeReceiptURL() + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: testReceiptURL) + + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + writeReceiptData(to: testReceiptURL) + callback(.success) + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { _ in + + } + XCTAssertTrue(validator.validateCalled) + removeReceiptData(at: testReceiptURL) + } + + // MARK: non-refresh tests (receipt url and data are set) + func testVerifyReceipt_when_appStoreReceiptURLIsNotNil_hasReceiptData_then_refreshNotCalled_validateIsCalled() { + + let testReceiptURL = makeReceiptURL() + writeReceiptData(to: testReceiptURL) + + let validator = TestReceiptValidator() + let verificator = InAppReceiptVerificator(appStoreReceiptURL: testReceiptURL) + + verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in + + XCTFail("refresh should not be called if we already have a receipt") + return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback) + + }) { _ in + + } + XCTAssertTrue(validator.validateCalled) + removeReceiptData(at: testReceiptURL) + } + + // MARK: Helpers + func makeReceiptURL() -> URL { + + guard let testFolderURL = try? FileManager.default.url(for: .documentDirectory, in: .allDomainsMask, appropriateFor: nil, create: false) else { + fatalError("Invalid test folder") + } + return testFolderURL.appendingPathComponent("receipt.data") + } + + func writeReceiptData(to url: URL) { + + guard let testReceiptData = NSData(base64Encoded: "encrypted-receipt", options: .ignoreUnknownCharacters) else { + fatalError("Invalid receipt data") + } + try? testReceiptData.write(to: url) + } + + func removeReceiptData(at url: URL) { + try? FileManager.default.removeItem(at: url) + } + +} From 8fba86de5f8ab327a6e5b150b5021efd4e1e44bd Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 18 May 2017 08:37:48 +0200 Subject: [PATCH 3/8] Update receipt verification in README --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a10753e9..dd45f474 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,20 @@ According to [Apple - Delivering Products](https://developer.apple.com/library/c > Information about all other kinds of purchases is added to the receipt when they’re paid for and remains in the receipt indefinitely. +When an app is first installed, the app receipt is missing. + +As soon as a user completes a purchase or restores purchases, StoreKit creates and stores the receipt locally as a file. + +As the local receipt is always encrypted, a verification step is needed to get all the receipt fields in readable form. + +This is done with a `verifyReceipt` method which does two things: + +- If the receipt is missing, refresh it +- If the receipt is available, validate it + +Receipt validation can be done remotely with Apple via the `AppleReceiptValidator` class, or with a client-supplied validator conforming to the `ReceiptValidator` protocol. + +**Note**: As of version 0.10.0, _clients no longer need to refresh the receipt explicitly_. ### Retrieve local receipt @@ -214,8 +228,7 @@ let receiptString = receiptData.base64EncodedString(options: []) ```swift let appleValidator = AppleReceiptValidator(service: .production) -let password = "your-shared-secret" -SwiftyStoreKit.verifyReceipt(using: appleValidator, password: password) { result in +SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in switch result { case .success(let receipt): print("Verify receipt Success: \(receipt)") From f72f6af98ffb21d5232c45b2ebc45a914ff0bc28 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 18 May 2017 10:47:49 +0100 Subject: [PATCH 4/8] Improve documentation --- README.md | 30 ++++++++++++++----- SwiftyStoreKit/AppleReceiptValidator.swift | 2 ++ SwiftyStoreKit/InAppReceiptVerificator.swift | 15 +++++----- SwiftyStoreKit/SwiftyStoreKit.swift | 31 +++++++++++++++++--- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index dd45f474..602c71d8 100644 --- a/README.md +++ b/README.md @@ -210,11 +210,19 @@ As the local receipt is always encrypted, a verification step is needed to get a This is done with a `verifyReceipt` method which does two things: - If the receipt is missing, refresh it -- If the receipt is available, validate it +- If the receipt is available or is refreshed, validate it Receipt validation can be done remotely with Apple via the `AppleReceiptValidator` class, or with a client-supplied validator conforming to the `ReceiptValidator` protocol. -**Note**: As of version 0.10.0, _clients no longer need to refresh the receipt explicitly_. +**Notes** + +* If the local receipt is missing when calling `verifyReceipt`, a network call is made to refresh it. +* If the user is not logged to the App Store, StoreKit will present a popup asking to **Sign In to the iTunes Store**. +* If the user enters valid credentials, the receipt will be refreshed and verified. +* If the user cancels, receipt refresh will fail with a **Cannot connect to iTunes Store** error. +* Using `AppleReceiptValidator` (see below) does remote receipt validation and also results in a network call. +* Local receipt validation is not implemented (see [issue #101](https://github.com/bizz84/SwiftyStoreKit/issues/101) for details). + ### Retrieve local receipt @@ -238,13 +246,16 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre } ``` -#### Notes +## Verifying purchases and subscriptions -* If the user is not logged to iTunes when `verifyReceipt` is called, StoreKit will present a popup asking to **Sign In to the iTunes Store**. -* If the user enters valid credentials, the receipt will be refreshed and verified. -* If the user cancels, receipt refresh will fail with a **Cannot connect to iTunes Store** error. -* The receipt is only refreshed if it's not already stored in `Bundle.main.appStoreReceiptURL`. +Once you have retrieved the receipt using the `verifyReceipt` method, you can verify your purchases and subscriptions by product identifier. + +Verifying multiple purchases and subscriptions in one call is not yet supported (see [issue #194](https://github.com/bizz84/SwiftyStoreKit/issues/194) for more details). +If you need to verify multiple purchases / subscriptions, you can either: + +* manually parse the receipt dictionary returned by `verifyReceipt` +* call `verifyPurchase` or `verifySubscription` multiple times with different product identifiers ### Verify Purchase @@ -325,7 +336,10 @@ let purchaseResult = SwiftyStoreKit.verifySubscription( inReceipt: receipt) ``` -**Note**: When purchasing subscriptions in sandbox mode, the expiry dates are set just minutes after the purchase date for testing purposes. +**Notes** + +* The expiration dates are calculated against the receipt date. This is the date of the last successful call to `verifyReceipt`. +* When purchasing subscriptions in sandbox mode, the expiry dates are set just minutes after the purchase date for testing purposes. #### Purchasing and verifying a subscription diff --git a/SwiftyStoreKit/AppleReceiptValidator.swift b/SwiftyStoreKit/AppleReceiptValidator.swift index 16d849b3..21bf5b39 100644 --- a/SwiftyStoreKit/AppleReceiptValidator.swift +++ b/SwiftyStoreKit/AppleReceiptValidator.swift @@ -25,6 +25,8 @@ import Foundation +// https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html + public struct AppleReceiptValidator: ReceiptValidator { public enum VerifyReceiptURLType: String { diff --git a/SwiftyStoreKit/InAppReceiptVerificator.swift b/SwiftyStoreKit/InAppReceiptVerificator.swift index e375014a..58f4761f 100644 --- a/SwiftyStoreKit/InAppReceiptVerificator.swift +++ b/SwiftyStoreKit/InAppReceiptVerificator.swift @@ -43,9 +43,12 @@ class InAppReceiptVerificator: NSObject { private var receiptRefreshRequest: InAppReceiptRefreshRequest? /** - * Verify application receipt - * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). - * - Parameter session: the session used to make remote call. + * Verify application receipt. This method does two things: + * * If the receipt is missing, refresh it + * * If the receipt is available or is refreshed, validate it + * - Parameter validator: Validator to check the encrypted receipt and return the receipt in readable format + * - Parameter password: Your app’s shared secret (a hexadecimal string). Only used for receipts that contain auto-renewable subscriptions. + * - Parameter refresh: closure to perform receipt refresh (this is made explicit for testability) * - Parameter completion: handler for result */ public func verifyReceipt(using validator: ReceiptValidator, @@ -76,12 +79,10 @@ class InAppReceiptVerificator: NSObject { } } - // https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html - /** * - Parameter receiptData: encrypted receipt data - * - Parameter validator: the validator to use - * - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string). + * - Parameter validator: Validator to check the encrypted receipt and return the receipt in readable format + * - Parameter password: Your app’s shared secret (a hexadecimal string). Only used for receipts that contain auto-renewable subscriptions. * - Parameter completion: handler for result */ private func verify(receiptData: Data, using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) { diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index 3b4413f8..c506f5e8 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -33,11 +33,12 @@ public class SwiftyStoreKit { fileprivate let receiptVerificator: InAppReceiptVerificator init(productsInfoController: ProductsInfoController = ProductsInfoController(), - paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default())) { + paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()), + receiptVerificator: InAppReceiptVerificator = InAppReceiptVerificator()) { self.productsInfoController = productsInfoController self.paymentQueueController = paymentQueueController - self.receiptVerificator = InAppReceiptVerificator() + self.receiptVerificator = receiptVerificator } // MARK: Internal methods @@ -138,10 +139,16 @@ extension SwiftyStoreKit { fileprivate static let sharedInstance = SwiftyStoreKit() // MARK: Public methods - Purchases + public class var canMakePayments: Bool { return SKPaymentQueue.canMakePayments() } + /** + * Retrieve products information + * - Parameter productIds: The set of product identifiers to retrieve corresponding products for + * - Parameter completion: handler for result + */ public class func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> Void) { return sharedInstance.retrieveProductsInfo(productIds, completion: completion) @@ -160,16 +167,32 @@ extension SwiftyStoreKit { sharedInstance.purchaseProduct(productId, quantity: quantity, atomically: atomically, applicationUsername: applicationUsername, completion: completion) } + /** + * Restore purchases + * - Parameter atomically: whether the product is purchased atomically (e.g. finishTransaction is called immediately) + * - Parameter applicationUsername: an opaque identifier for the user’s account on your system + * - Parameter completion: handler for result + */ public class func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> Void) { sharedInstance.restorePurchases(atomically: atomically, applicationUsername: applicationUsername, completion: completion) } + /** + * Complete transactions + * - Parameter atomically: whether the product is purchased atomically (e.g. finishTransaction is called immediately) + * - Parameter completion: handler for result + */ public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Purchase]) -> Void) { sharedInstance.completeTransactions(atomically: atomically, completion: completion) } + /** + * Finish a transaction + * Once the content has been delivered, call this method to finish a transaction that was performed non-atomically + * - Parameter transaction: transaction to finish + */ public class func finishTransaction(_ transaction: PaymentTransaction) { sharedInstance.finishTransaction(transaction) @@ -211,11 +234,11 @@ extension SwiftyStoreKit { /** * Verify the purchase of a subscription (auto-renewable, free or non-renewing) in a receipt. This method extracts all transactions mathing the given productId and sorts them by date in descending order, then compares the first transaction expiry date against the validUntil value. + * - Parameter type: autoRenewable or nonRenewing * - Parameter productId: the product id of the purchase to verify * - Parameter inReceipt: the receipt to use for looking up the subscription * - Parameter validUntil: date to check against the expiry date of the subscription. If nil, no verification - * - Parameter validDuration: the duration of the subscription. Only required for non-renewable subscription. - * - return: either NotPurchased or Purchased / Expired with the expiry date found in the receipt + * - return: either .notPurchased or .purchased / .expired with the expiry date found in the receipt */ public class func verifySubscription(type: SubscriptionType, productId: String, inReceipt receipt: ReceiptInfo, validUntil date: Date = Date()) -> VerifySubscriptionResult { From f09c582c7c8e6ae775f5518dbb7ff9125647b0f0 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 18 May 2017 11:22:06 +0100 Subject: [PATCH 5/8] Add missing swift markup to code snippets in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 602c71d8..4b526c5e 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre ``` #### Auto-Renewable -``` +```swift let purchaseResult = SwiftyStoreKit.verifySubscription( type: .autoRenewable, productId: "com.musevisions.SwiftyStoreKit.Subscription", @@ -328,7 +328,7 @@ let purchaseResult = SwiftyStoreKit.verifySubscription( ``` #### Non-Renewing -``` +```swift // validDuration: time interval in seconds let purchaseResult = SwiftyStoreKit.verifySubscription( type: .nonRenewing(validDuration: 3600 * 24 * 30), From de30875da57bd464ac30be009373c058fb5d446d Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 18 May 2017 11:37:36 +0100 Subject: [PATCH 6/8] Remove unused line on macOS demo --- SwiftyStoreKit-macOS-Demo/ViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/SwiftyStoreKit-macOS-Demo/ViewController.swift b/SwiftyStoreKit-macOS-Demo/ViewController.swift index fa6f8276..d9944e82 100644 --- a/SwiftyStoreKit-macOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-macOS-Demo/ViewController.swift @@ -119,7 +119,6 @@ class ViewController: NSViewController { func verifyPurchase(_ purchase: RegisteredPurchase) { - let appleValidator = AppleReceiptValidator(service: .production) verifyReceipt { result in switch result { From 8788468aa811b870f455581daf605bb918699e98 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 18 May 2017 11:47:14 +0100 Subject: [PATCH 7/8] Fix infinite recursion issue on macOS demo --- SwiftyStoreKit-macOS-Demo/ViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SwiftyStoreKit-macOS-Demo/ViewController.swift b/SwiftyStoreKit-macOS-Demo/ViewController.swift index d9944e82..667516f1 100644 --- a/SwiftyStoreKit-macOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-macOS-Demo/ViewController.swift @@ -105,9 +105,9 @@ class ViewController: NSViewController { @IBAction func verifyReceipt(_ sender: Any?) { - verifyReceipt { result in + verifyReceipt(completion: { result in self.showAlert(self.alertForVerifyReceipt(result)) - } + }) } func verifyReceipt(completion: @escaping (VerifyReceiptResult) -> Void) { From 0a794db3922ec078921a85c7a9c24519cf490c3f Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 18 May 2017 11:47:59 +0100 Subject: [PATCH 8/8] Update no receipt data error message on demo apps --- SwiftyStoreKit-iOS-Demo/ViewController.swift | 2 +- SwiftyStoreKit-macOS-Demo/ViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SwiftyStoreKit-iOS-Demo/ViewController.swift b/SwiftyStoreKit-iOS-Demo/ViewController.swift index accd7299..563315f9 100644 --- a/SwiftyStoreKit-iOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-iOS-Demo/ViewController.swift @@ -254,7 +254,7 @@ extension ViewController { print("Verify receipt Failed: \(error)") switch error { case .noReceiptData: - return alertWithTitle("Receipt verification", message: "No receipt data, application will try to get a new one. Try again.") + return alertWithTitle("Receipt verification", message: "No receipt data. Try again.") case .networkError(let error): return alertWithTitle("Receipt verification", message: "Network error while verifying receipt: \(error)") default: diff --git a/SwiftyStoreKit-macOS-Demo/ViewController.swift b/SwiftyStoreKit-macOS-Demo/ViewController.swift index 667516f1..d9a682a7 100644 --- a/SwiftyStoreKit-macOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-macOS-Demo/ViewController.swift @@ -237,7 +237,7 @@ extension ViewController { print("Verify receipt Failed: \(error)") switch error { case .noReceiptData: - return alertWithTitle("Receipt verification", message: "No receipt data, application will try to get a new one. Try again.") + return alertWithTitle("Receipt verification", message: "No receipt data. Try again.") case .networkError(let error): return alertWithTitle("Receipt verification", message: "Network error while verifying receipt: \(error)") default: