diff --git a/README.md b/README.md index 6e7e4a72..4b526c5e 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,28 @@ 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 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. + +**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 @@ -215,31 +237,25 @@ 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)") - } - } + switch result { + case .success(let receipt): + print("Verify receipt Success: \(receipt)") + case .error(let error): + print("Verify receipt Failed: \(error)") + } } ``` -#### Notes +## Verifying purchases and subscriptions -* 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 cancels, receipt refresh will fail with a **Cannot connect to iTunes Store** error. +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 @@ -304,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", @@ -312,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), @@ -320,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-iOS-Demo/ViewController.swift b/SwiftyStoreKit-iOS-Demo/ViewController.swift index a6b41689..563315f9 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 : - return alertWithTitle("Receipt verification", message: "No receipt data, application will try to get a new one. Try again.") + case .noReceiptData: + 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: - 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..d9a682a7 100644 --- a/SwiftyStoreKit-macOS-Demo/ViewController.swift +++ b/SwiftyStoreKit-macOS-Demo/ViewController.swift @@ -105,24 +105,21 @@ class ViewController: NSViewController { @IBAction func verifyReceipt(_ sender: Any?) { + verifyReceipt(completion: { result in + self.showAlert(self.alertForVerifyReceipt(result)) + }) + } + + func verifyReceipt(completion: @escaping (VerifyReceiptResult) -> Void) { + 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() - } - } - } - } + 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 +154,6 @@ class ViewController: NSViewController { } } } - - func refreshReceipt() { - - SwiftyStoreKit.refreshReceipt { result in - - self.showAlert(self.alertForRefreshReceipt(result)) - } - } - } // MARK: User facing alerts @@ -244,10 +232,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. 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 +272,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..6d698c06 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -47,6 +47,10 @@ 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 */; }; 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 +183,8 @@ 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 = ""; }; 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -306,6 +312,7 @@ 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */, C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */, C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */, + 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */, 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */, 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */, C40C680F1C29414C00B60B7E /* OS.swift */, @@ -333,6 +340,7 @@ 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */, C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */, 65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */, + 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */, 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */, 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, C3099C081E2FCE3A00392A54 /* TestProduct.swift */, @@ -710,6 +718,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 +749,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 */, @@ -764,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 */, @@ -786,6 +797,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 */, 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/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/InAppReceiptRefreshRequest.swift b/SwiftyStoreKit/InAppReceiptRefreshRequest.swift index b1ab45c8..eea370b1 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 new file mode 100644 index 00000000..58f4761f --- /dev/null +++ b/SwiftyStoreKit/InAppReceiptVerificator.swift @@ -0,0 +1,100 @@ +// +// 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 { + + let appStoreReceiptURL: URL? + init(appStoreReceiptURL: URL? = Bundle.main.appStoreReceiptURL) { + self.appStoreReceiptURL = appStoreReceiptURL + } + + 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. 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, + password: String? = nil, + refresh: InAppReceiptRefreshRequest.ReceiptRefresh = InAppReceiptRefreshRequest.refresh, + completion: @escaping (VerifyReceiptResult) -> Void) { + + if let receiptData = appStoreReceiptData { + + verify(receiptData: receiptData, using: validator, password: password, completion: completion) + } else { + + receiptRefreshRequest = refresh(nil) { result in + + self.receiptRefreshRequest = nil + + switch result { + case .success: + if let receiptData = self.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))) + } + } + } + } + + /** + * - Parameter receiptData: encrypted receipt data + * - 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) { + + // 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..c506f5e8 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -30,13 +30,15 @@ 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())) { + paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()), + receiptVerificator: InAppReceiptVerificator = InAppReceiptVerificator()) { self.productsInfoController = productsInfoController self.paymentQueueController = paymentQueueController + self.receiptVerificator = receiptVerificator } // MARK: Internal methods @@ -83,24 +85,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,13 +136,19 @@ 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 { 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) @@ -177,26 +167,36 @@ 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) } - - // 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,55 +207,41 @@ 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 sharedInstance.receiptVerificator.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) { - - InAppReceipt.verify(using: validator, password: password) { result in + public class func verifyReceipt(using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) { - 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) } /** * 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 { + 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) } } 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) + } + +}