From ca5239b2899b46b5e3f15260824358a10cb29362 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 17 Jan 2017 22:38:41 +0000 Subject: [PATCH 01/31] Started implementing PaymentQueueController and associated tests --- SwiftyStoreKit.xcodeproj/project.pbxproj | 147 +++++++++++++++++- .../xcschemes/xcschememanagement.plist | 10 ++ SwiftyStoreKit/PaymentQueueController.swift | 102 ++++++++++++ SwiftyStoreKitTests/Info.plist | 22 +++ .../PaymentQueueControllerTests.swift | 88 +++++++++++ SwiftyStoreKitTests/PaymentQueueSpy.swift | 41 +++++ .../PaymentTransactionObserverFake.swift | 13 ++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 SwiftyStoreKit/PaymentQueueController.swift create mode 100644 SwiftyStoreKitTests/Info.plist create mode 100644 SwiftyStoreKitTests/PaymentQueueControllerTests.swift create mode 100644 SwiftyStoreKitTests/PaymentQueueSpy.swift create mode 100644 SwiftyStoreKitTests/PaymentTransactionObserverFake.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 1c7a891f..3b2c4970 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -25,9 +25,16 @@ 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; + 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */; }; + 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */; }; + 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */; }; + 658A08431E2EC5120074A98F /* SwiftyStoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */; }; + 658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */; }; + 658A084C1E2EC5960074A98F /* PaymentQueueSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */; }; 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 */; }; + 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */; }; 65F7DF711DCD4DF000835D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */; }; 65F7DF721DCD4DF000835D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF691DCD4DF000835D30 /* Assets.xcassets */; }; 65F7DF731DCD4DF000835D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF6A1DCD4DF000835D30 /* LaunchScreen.storyboard */; }; @@ -57,6 +64,20 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 658A08441E2EC5120074A98F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6502F5F61B985833004E342D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6502F62C1B985C40004E342D; + remoteInfo = SwiftyStoreKit_iOS; + }; + 658A084D1E2EC83F0074A98F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 6502F5F61B985833004E342D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6502F5FD1B985833004E342D; + remoteInfo = SwiftyStoreKit_iOSDemo; + }; 65F7DF901DCD524300835D30 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6502F5F61B985833004E342D /* Project object */; @@ -108,7 +129,13 @@ 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppCompleteTransactionsObserver.swift; sourceTree = ""; }; 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = ""; }; + 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = ""; }; + 658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftyStoreKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 658A08421E2EC5120074A98F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueControllerTests.swift; sourceTree = ""; }; + 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = ""; }; 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = ""; }; + 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = ""; }; 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 65F7DF691DCD4DF000835D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 65F7DF6B1DCD4DF000835D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -157,6 +184,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 658A083B1E2EC5120074A98F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 658A08431E2EC5120074A98F /* SwiftyStoreKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C4D74BB71C24CEC90071AD3E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -181,6 +216,7 @@ 6502F6001B985833004E342D /* SwiftyStoreKit */, 65F7DF671DCD4DF000835D30 /* SwiftyStoreKit-iOS-Demo */, 65F7DF7D1DCD4FC500835D30 /* SwiftyStoreKit-macOS-Demo */, + 658A083F1E2EC5120074A98F /* SwiftyStoreKitTests */, 6502F5FF1B985833004E342D /* Products */, ); sourceTree = ""; @@ -193,6 +229,7 @@ C4D74BBB1C24CEC90071AD3E /* SwiftyStoreKit.framework */, C4FD3A011C2954C10035CFF3 /* SwiftyStoreKit_macOSDemo.app */, 54C0D52C1CF7404500F90BCE /* SwiftyStoreKit.framework */, + 658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */, ); name = Products; sourceTree = ""; @@ -211,10 +248,22 @@ 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */, C40C680F1C29414C00B60B7E /* OS.swift */, 65F7DF931DCD536100835D30 /* Platforms */, + 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, ); path = SwiftyStoreKit; sourceTree = ""; }; + 658A083F1E2EC5120074A98F /* SwiftyStoreKitTests */ = { + isa = PBXGroup; + children = ( + 658A08421E2EC5120074A98F /* Info.plist */, + 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */, + 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */, + 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, + ); + path = SwiftyStoreKitTests; + sourceTree = ""; + }; 65F7DF671DCD4DF000835D30 /* SwiftyStoreKit-iOS-Demo */ = { isa = PBXGroup; children = ( @@ -339,6 +388,25 @@ productReference = 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */; productType = "com.apple.product-type.framework"; }; + 658A083D1E2EC5120074A98F /* SwiftyStoreKitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 658A08461E2EC5120074A98F /* Build configuration list for PBXNativeTarget "SwiftyStoreKitTests" */; + buildPhases = ( + 658A083A1E2EC5120074A98F /* Sources */, + 658A083B1E2EC5120074A98F /* Frameworks */, + 658A083C1E2EC5120074A98F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 658A08451E2EC5120074A98F /* PBXTargetDependency */, + 658A084E1E2EC83F0074A98F /* PBXTargetDependency */, + ); + name = SwiftyStoreKitTests; + productName = SwiftyStoreKitTests; + productReference = 658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; C4D74BBA1C24CEC90071AD3E /* SwiftyStoreKit_macOS */ = { isa = PBXNativeTarget; buildConfigurationList = C4D74BC21C24CECA0071AD3E /* Build configuration list for PBXNativeTarget "SwiftyStoreKit_macOS" */; @@ -382,7 +450,7 @@ 6502F5F61B985833004E342D /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0720; + LastSwiftUpdateCheck = 0820; LastUpgradeCheck = 0810; ORGANIZATIONNAME = musevisions; TargetAttributes = { @@ -398,6 +466,11 @@ CreatedOnToolsVersion = 7.0; LastSwiftMigration = 0800; }; + 658A083D1E2EC5120074A98F = { + CreatedOnToolsVersion = 8.2.1; + ProvisioningStyle = Automatic; + TestTargetID = 6502F5FD1B985833004E342D; + }; C4D74BBA1C24CEC90071AD3E = { CreatedOnToolsVersion = 7.2; }; @@ -425,6 +498,7 @@ 54C0D52B1CF7404500F90BCE /* SwiftyStoreKit_tvOS */, 6502F5FD1B985833004E342D /* SwiftyStoreKit_iOSDemo */, C4FD3A001C2954C10035CFF3 /* SwiftyStoreKit_macOSDemo */, + 658A083D1E2EC5120074A98F /* SwiftyStoreKitTests */, ); }; /* End PBXProject section */ @@ -454,6 +528,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 658A083C1E2EC5120074A98F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C4D74BB91C24CEC90071AD3E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -483,6 +564,7 @@ 54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */, 54B069961CF744DC00BAFE38 /* OS.swift in Sources */, 54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */, + 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, 54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */, 65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */, @@ -510,6 +592,7 @@ 6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */, 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */, C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */, + 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */, 65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */, @@ -517,6 +600,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 658A083A1E2EC5120074A98F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */, + 658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */, + 658A084C1E2EC5960074A98F /* PaymentQueueSpy.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C4D74BB61C24CEC90071AD3E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -527,6 +620,7 @@ C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */, C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */, C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */, + 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */, 65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */, @@ -546,6 +640,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 658A08451E2EC5120074A98F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6502F62C1B985C40004E342D /* SwiftyStoreKit_iOS */; + targetProxy = 658A08441E2EC5120074A98F /* PBXContainerItemProxy */; + }; + 658A084E1E2EC83F0074A98F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6502F5FD1B985833004E342D /* SwiftyStoreKit_iOSDemo */; + targetProxy = 658A084D1E2EC83F0074A98F /* PBXContainerItemProxy */; + }; 65F7DF911DCD524300835D30 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6502F62C1B985C40004E342D /* SwiftyStoreKit_iOS */; @@ -806,6 +910,38 @@ }; name = Release; }; + 658A08471E2EC5120074A98F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + INFOPLIST_FILE = SwiftyStoreKitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.musevisions.iOS.SwiftyStoreKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 3.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftyStoreKit_iOSDemo.app/SwiftyStoreKit_iOSDemo"; + }; + name = Debug; + }; + 658A08481E2EC5120074A98F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + INFOPLIST_FILE = SwiftyStoreKitTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 10.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.musevisions.iOS.SwiftyStoreKitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_VERSION = 3.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftyStoreKit_iOSDemo.app/SwiftyStoreKit_iOSDemo"; + }; + name = Release; + }; C4D74BC01C24CECA0071AD3E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -927,6 +1063,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 658A08461E2EC5120074A98F /* Build configuration list for PBXNativeTarget "SwiftyStoreKitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 658A08471E2EC5120074A98F /* Debug */, + 658A08481E2EC5120074A98F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C4D74BC21C24CECA0071AD3E /* Build configuration list for PBXNativeTarget "SwiftyStoreKit_macOS" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist b/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist index f4347d4a..23f954a7 100644 --- a/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist @@ -39,6 +39,11 @@ orderHint 1 + SwiftyStoreKitTests.xcscheme + + orderHint + 5 + SuppressBuildableAutocreation @@ -57,6 +62,11 @@ primary + 658A083D1E2EC5120074A98F + + primary + + C4D74BBA1C24CEC90071AD3E primary diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift new file mode 100644 index 00000000..768c3448 --- /dev/null +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -0,0 +1,102 @@ +// +// PaymentQueueController.swift +// SwiftyStoreKit +// +// 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 +import StoreKit + +public protocol PaymentQueue: class { + + func add(_ observer: SKPaymentTransactionObserver) + func remove(_ observer: SKPaymentTransactionObserver) + + func add(_ payment: SKPayment) + + func restoreCompletedTransactions() +} + +extension SKPaymentQueue: PaymentQueue { } + +public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { + + public enum TransactionResult { + case purchased(product: Product) + case restored(product: Product) + case failed(error: Error) + } + + public struct Payment { + public let product: SKProduct + public let atomically: Bool + public let applicationUsername: String + public let callback: (TransactionResult) -> () + } + + public struct RestorePurchases { + let atomically: Bool + let callback: ([TransactionResult]) -> () + } + + unowned let paymentQueue: PaymentQueue + + deinit { + paymentQueue.remove(self) + } + + public init(paymentQueue: PaymentQueue = SKPaymentQueue.default()) { + + self.paymentQueue = paymentQueue + super.init() + paymentQueue.add(self) + } + + public func startPayment(_ payment: Payment) { + + let skPayment = SKMutablePayment(product: payment.product) + skPayment.applicationUsername = payment.applicationUsername + paymentQueue.add(skPayment) + } + + + // MARK: SKPaymentTransactionObserver + public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + + } + + public func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { + + } + + public func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { + + } + + public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { + + } + + public func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) { + + } + +} diff --git a/SwiftyStoreKitTests/Info.plist b/SwiftyStoreKitTests/Info.plist new file mode 100644 index 00000000..6c6c23c4 --- /dev/null +++ b/SwiftyStoreKitTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift new file mode 100644 index 00000000..2959d677 --- /dev/null +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -0,0 +1,88 @@ +// +// PaymentQueueControllerTests.swift +// SwiftyStoreKit +// +// 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 +import SwiftyStoreKit +import StoreKit + +extension PaymentQueueController.Payment { + public init(product: SKProduct, atomically: Bool, applicationUsername: String, callback: @escaping (PaymentQueueController.TransactionResult) -> ()) { + self.product = product + self.atomically = atomically + self.applicationUsername = applicationUsername + self.callback = callback + } +} + +class PaymentQueueControllerTests: XCTestCase { + + class TestProduct: SKProduct { + + var _productIdentifier: String = "" + + override var productIdentifier: String { + return _productIdentifier + } + + init(productIdentifier: String) { + _productIdentifier = productIdentifier + super.init() + } + } + + // MARK: init/deinit + func testInit_registersAsObserver() { + + let spy = PaymentQueueSpy() + + let paymentQueueController = PaymentQueueController(paymentQueue: spy) + + XCTAssertTrue(spy.observer === paymentQueueController) + } + + func testDeinit_removesObserver() { + + let spy = PaymentQueueSpy() + + let _ = PaymentQueueController(paymentQueue: spy) + + XCTAssertNil(spy.observer) + } + + // MARK: Start payment + + func testStartTransaction_QueuesOnePayment() { + + let spy = PaymentQueueSpy() + + let paymentQueueController = PaymentQueueController(paymentQueue: spy) + + let product = TestProduct(productIdentifier: "com.SwiftyStoreKit.product1") + let payment = PaymentQueueController.Payment(product: product, atomically: true, applicationUsername: "", callback: { result in }) + + paymentQueueController.startPayment(payment) + + XCTAssertEqual(spy.payments.count, 1) + } +} diff --git a/SwiftyStoreKitTests/PaymentQueueSpy.swift b/SwiftyStoreKitTests/PaymentQueueSpy.swift new file mode 100644 index 00000000..832fb853 --- /dev/null +++ b/SwiftyStoreKitTests/PaymentQueueSpy.swift @@ -0,0 +1,41 @@ +// +// PaymentQueueSpy.swift +// SwiftyStoreKit +// +// Created by Andrea Bizzotto on 17/01/2017. +// Copyright © 2017 musevisions. All rights reserved. +// + +import SwiftyStoreKit +import StoreKit + +class PaymentQueueSpy: PaymentQueue { + + weak var observer: SKPaymentTransactionObserver? + + var payments: [SKPayment] = [] + + var restoreCompletedTransactionCalledCount = 0 + + func add(_ observer: SKPaymentTransactionObserver) { + + self.observer = observer + } + func remove(_ observer: SKPaymentTransactionObserver) { + + if self.observer === observer { + self.observer = nil + } + } + + func add(_ payment: SKPayment) { + + payments.append(payment) + } + + func restoreCompletedTransactions() { + + restoreCompletedTransactionCalledCount += 1 + } + +} diff --git a/SwiftyStoreKitTests/PaymentTransactionObserverFake.swift b/SwiftyStoreKitTests/PaymentTransactionObserverFake.swift new file mode 100644 index 00000000..4138211b --- /dev/null +++ b/SwiftyStoreKitTests/PaymentTransactionObserverFake.swift @@ -0,0 +1,13 @@ +// +// PaymentTransactionObserverFake.swift +// SwiftyStoreKit +// +// Created by Andrea Bizzotto on 17/01/2017. +// Copyright © 2017 musevisions. All rights reserved. +// + +import UIKit + +class PaymentTransactionObserverFake: NSObject { + +} From 64c7325220baeb2cf14292e257a8c28b80709a86 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Tue, 17 Jan 2017 23:16:04 +0000 Subject: [PATCH 02/31] Add PaymentsController --- SwiftyStoreKit.xcodeproj/project.pbxproj | 8 +++ SwiftyStoreKit/PaymentQueueController.swift | 56 +++++++++++----- SwiftyStoreKit/Payments.swift | 67 +++++++++++++++++++ .../PaymentQueueControllerTests.swift | 6 +- SwiftyStoreKitTests/PaymentQueueSpy.swift | 3 + 5 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 SwiftyStoreKit/Payments.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 3b2c4970..83033148 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -35,6 +35,9 @@ 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 */; }; 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */; }; + 65F70AC91E2EDC3700BF040D /* Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* Payments.swift */; }; + 65F70ACA1E2EDC3700BF040D /* Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* Payments.swift */; }; + 65F70ACB1E2EDC3700BF040D /* Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* Payments.swift */; }; 65F7DF711DCD4DF000835D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */; }; 65F7DF721DCD4DF000835D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF691DCD4DF000835D30 /* Assets.xcassets */; }; 65F7DF731DCD4DF000835D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF6A1DCD4DF000835D30 /* LaunchScreen.storyboard */; }; @@ -136,6 +139,7 @@ 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = ""; }; 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = ""; }; 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = ""; }; + 65F70AC81E2EDC3700BF040D /* Payments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Payments.swift; sourceTree = ""; }; 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 65F7DF691DCD4DF000835D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 65F7DF6B1DCD4DF000835D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -249,6 +253,7 @@ C40C680F1C29414C00B60B7E /* OS.swift */, 65F7DF931DCD536100835D30 /* Platforms */, 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, + 65F70AC81E2EDC3700BF040D /* Payments.swift */, ); path = SwiftyStoreKit; sourceTree = ""; @@ -564,6 +569,7 @@ 54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */, 54B069961CF744DC00BAFE38 /* OS.swift in Sources */, 54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */, + 65F70ACB1E2EDC3700BF040D /* Payments.swift in Sources */, 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, 54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */, @@ -592,6 +598,7 @@ 6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */, 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */, C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */, + 65F70AC91E2EDC3700BF040D /* Payments.swift in Sources */, 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */, @@ -620,6 +627,7 @@ C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */, C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */, C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */, + 65F70ACA1E2EDC3700BF040D /* Payments.swift in Sources */, 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */, diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 768c3448..da7ac8b3 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -33,39 +33,31 @@ public protocol PaymentQueue: class { func add(_ payment: SKPayment) func restoreCompletedTransactions() + + func finishTransaction(_ transaction: SKPaymentTransaction) } extension SKPaymentQueue: PaymentQueue { } public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { - public enum TransactionResult { - case purchased(product: Product) - case restored(product: Product) - case failed(error: Error) - } - - public struct Payment { - public let product: SKProduct - public let atomically: Bool - public let applicationUsername: String - public let callback: (TransactionResult) -> () - } - public struct RestorePurchases { let atomically: Bool let callback: ([TransactionResult]) -> () } - + + private let paymentsController: PaymentsController + unowned let paymentQueue: PaymentQueue deinit { paymentQueue.remove(self) } - public init(paymentQueue: PaymentQueue = SKPaymentQueue.default()) { + public init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), paymentsController: PaymentsController = PaymentsController()) { self.paymentQueue = paymentQueue + self.paymentsController = paymentsController super.init() paymentQueue.add(self) } @@ -75,12 +67,39 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { let skPayment = SKMutablePayment(product: payment.product) skPayment.applicationUsername = payment.applicationUsername paymentQueue.add(skPayment) + + paymentsController.insert(payment) } // MARK: SKPaymentTransactionObserver public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + for transaction in transactions { + + let transactionState = transaction.transactionState + + switch transactionState { + case .purchased: + + let _ = paymentsController.processTransaction(transaction, paymentQueue: paymentQueue) + + break + case .failed: + break + + case .restored: + break + + case .purchasing: + // In progress: do nothing + break + case .deferred: + break + } + + } + } public func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { @@ -100,3 +119,10 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { } } + +/* + If more than one payment is queued for a given product Id, + only the first callback should be called to ensure the content is delivered only once + + + */ diff --git a/SwiftyStoreKit/Payments.swift b/SwiftyStoreKit/Payments.swift new file mode 100644 index 00000000..f6a0c892 --- /dev/null +++ b/SwiftyStoreKit/Payments.swift @@ -0,0 +1,67 @@ +// +// Payments.swift +// SwiftyStoreKit +// +// Created by Andrea Bizzotto on 17/01/2017. +// Copyright © 2017 musevisions. All rights reserved. +// + +import Foundation +import StoreKit + +public enum TransactionResult { + case purchased(product: Product) + case restored(product: Product) + case failed(error: Error) +} + +public struct Payment: Hashable { + public let product: SKProduct + public let atomically: Bool + public let applicationUsername: String + public let callback: (TransactionResult) -> () + + public var hashValue: Int { + return product.productIdentifier.hashValue + } + public static func ==(lhs: Payment, rhs: Payment) -> Bool { + return lhs.product.productIdentifier == rhs.product.productIdentifier + } +} + +public class PaymentsController { + + private var payments: Set = [] + + private func findPayment(withProductIdentifier identifier: String) -> Payment? { + for payment in payments { + if payment.product.productIdentifier == identifier { + return payment + } + } + return nil + } + + public func insert(_ payment: Payment) { + payments.insert(payment) + } + + public func processTransaction(_ transaction: SKPaymentTransaction, paymentQueue: PaymentQueue) -> Bool { + + let transactionProductIdentifier = transaction.payment.productIdentifier + + if let payment = findPayment(withProductIdentifier: transactionProductIdentifier) { + + let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !payment.atomically) + + payment.callback(.purchased(product: product)) + + if payment.atomically { + paymentQueue.finishTransaction(transaction) + } + payments.remove(payment) + return true + } + return false + } +} diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift index 2959d677..51c2e7d9 100644 --- a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -26,8 +26,8 @@ import XCTest import SwiftyStoreKit import StoreKit -extension PaymentQueueController.Payment { - public init(product: SKProduct, atomically: Bool, applicationUsername: String, callback: @escaping (PaymentQueueController.TransactionResult) -> ()) { +extension Payment { + public init(product: SKProduct, atomically: Bool, applicationUsername: String, callback: @escaping (TransactionResult) -> ()) { self.product = product self.atomically = atomically self.applicationUsername = applicationUsername @@ -79,7 +79,7 @@ class PaymentQueueControllerTests: XCTestCase { let paymentQueueController = PaymentQueueController(paymentQueue: spy) let product = TestProduct(productIdentifier: "com.SwiftyStoreKit.product1") - let payment = PaymentQueueController.Payment(product: product, atomically: true, applicationUsername: "", callback: { result in }) + let payment = Payment(product: product, atomically: true, applicationUsername: "", callback: { result in }) paymentQueueController.startPayment(payment) diff --git a/SwiftyStoreKitTests/PaymentQueueSpy.swift b/SwiftyStoreKitTests/PaymentQueueSpy.swift index 832fb853..6ee0228d 100644 --- a/SwiftyStoreKitTests/PaymentQueueSpy.swift +++ b/SwiftyStoreKitTests/PaymentQueueSpy.swift @@ -38,4 +38,7 @@ class PaymentQueueSpy: PaymentQueue { restoreCompletedTransactionCalledCount += 1 } + func finishTransaction(_ transaction: SKPaymentTransaction) { + + } } From aef356df010a75b04a4497a4a09ce44364abcae3 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 18 Jan 2017 20:46:09 +0000 Subject: [PATCH 03/31] Implemented purchases in PaymentsController and started implementing RestorePurchasesController + tests --- SwiftyStoreKit.xcodeproj/project.pbxproj | 12 +++ .../SwiftyStoreKit-iOS-Demo.xcscheme | 10 ++ SwiftyStoreKit/PaymentQueueController.swift | 81 +++++++++------ SwiftyStoreKit/Payments.swift | 73 ++++++++++++-- .../PaymentQueueControllerTests.swift | 14 --- SwiftyStoreKitTests/PaymentQueueSpy.swift | 3 + .../PaymentsControllerTests.swift | 98 +++++++++++++++++++ .../TestPaymentTransaction.swift | 44 +++++++++ SwiftyStoreKitTests/TestProduct.swift | 39 ++++++++ 9 files changed, 321 insertions(+), 53 deletions(-) create mode 100644 SwiftyStoreKitTests/PaymentsControllerTests.swift create mode 100644 SwiftyStoreKitTests/TestPaymentTransaction.swift create mode 100644 SwiftyStoreKitTests/TestProduct.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 83033148..5b12ba80 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -53,6 +53,9 @@ 65F7DF9A1DCD536700835D30 /* SwiftyStoreKit-iOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF971DCD536100835D30 /* SwiftyStoreKit-iOS.h */; settings = {ATTRIBUTES = (Public, ); }; }; 65F7DF9B1DCD537800835D30 /* SwiftyStoreKit-macOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF981DCD536100835D30 /* SwiftyStoreKit-macOS.h */; settings = {ATTRIBUTES = (Public, ); }; }; 65F7DF9C1DCD537F00835D30 /* SwiftyStoreKit-tvOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF991DCD536100835D30 /* SwiftyStoreKit-tvOS.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */; }; + C3099C091E2FCE3A00392A54 /* TestProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C081E2FCE3A00392A54 /* TestProduct.swift */; }; + C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */; }; C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; }; C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; }; C40C68101C29414C00B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; }; @@ -158,6 +161,9 @@ 65F7DF971DCD536100835D30 /* SwiftyStoreKit-iOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-iOS.h"; sourceTree = ""; }; 65F7DF981DCD536100835D30 /* SwiftyStoreKit-macOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-macOS.h"; sourceTree = ""; }; 65F7DF991DCD536100835D30 /* SwiftyStoreKit-tvOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-tvOS.h"; sourceTree = ""; }; + C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentsControllerTests.swift; sourceTree = ""; }; + C3099C081E2FCE3A00392A54 /* TestProduct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestProduct.swift; sourceTree = ""; }; + C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestPaymentTransaction.swift; sourceTree = ""; }; C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptRefreshRequest.swift; sourceTree = ""; }; C40C680F1C29414C00B60B7E /* OS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OS.swift; sourceTree = ""; }; C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = ""; }; @@ -263,8 +269,11 @@ children = ( 658A08421E2EC5120074A98F /* Info.plist */, 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */, + C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */, 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */, 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, + C3099C081E2FCE3A00392A54 /* TestProduct.swift */, + C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */, ); path = SwiftyStoreKitTests; sourceTree = ""; @@ -611,9 +620,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */, + C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */, 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */, 658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */, 658A084C1E2EC5960074A98F /* PaymentQueueSpy.swift in Sources */, + C3099C091E2FCE3A00392A54 /* TestProduct.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKit-iOS-Demo.xcscheme b/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKit-iOS-Demo.xcscheme index 087757ee..556d6441 100644 --- a/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKit-iOS-Demo.xcscheme +++ b/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKit-iOS-Demo.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + () - } private let paymentsController: PaymentsController + private let restorePurchasesController: RestorePurchasesController + unowned let paymentQueue: PaymentQueue deinit { paymentQueue.remove(self) } - public init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), paymentsController: PaymentsController = PaymentsController()) { + public init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), + paymentsController: PaymentsController = PaymentsController(), + restorePurchasesController: RestorePurchasesController = RestorePurchasesController()) { self.paymentQueue = paymentQueue self.paymentsController = paymentsController + self.restorePurchasesController = restorePurchasesController super.init() paymentQueue.add(self) } public func startPayment(_ payment: Payment) { + if paymentsController.hasPayment(payment) { + // return .inProgress + return + } + let skPayment = SKMutablePayment(product: payment.product) skPayment.applicationUsername = payment.applicationUsername paymentQueue.add(skPayment) @@ -71,34 +76,50 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { paymentsController.insert(payment) } + public func startRestorePurchases(_ restorePurchases: RestorePurchases) { + + if restorePurchasesController.restorePurchases != nil { + // return .inProgress + return + } + + paymentQueue.restoreCompletedTransactions() + + restorePurchasesController.restorePurchases = restorePurchases + } + // MARK: SKPaymentTransactionObserver public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + + var unhandledTransactions = paymentsController.processTransactions(transactions, on: paymentQueue) + + unhandledTransactions = restorePurchasesController.processTransactions(unhandledTransactions, on: paymentQueue) - for transaction in transactions { - - let transactionState = transaction.transactionState - - switch transactionState { - case .purchased: - - let _ = paymentsController.processTransaction(transaction, paymentQueue: paymentQueue) - - break - case .failed: - break - - case .restored: - break - - case .purchasing: - // In progress: do nothing - break - case .deferred: - break - } - - } + // TODO: Complete transactions + +// for transaction in transactionsExcludingPayments { +// +// let transactionState = transaction.transactionState +// +// switch transactionState { +// case .purchased: +// +// break +// case .failed: +// break +// +// case .restored: +// break +// +// case .purchasing: +// // In progress: do nothing +// break +// case .deferred: +// break +// } +// +// } } diff --git a/SwiftyStoreKit/Payments.swift b/SwiftyStoreKit/Payments.swift index f6a0c892..8a308880 100644 --- a/SwiftyStoreKit/Payments.swift +++ b/SwiftyStoreKit/Payments.swift @@ -9,12 +9,28 @@ import Foundation import StoreKit + +public protocol TransactionController { + + /** + * - param transactions: transactions to process + * - param paymentQueue: payment queue for finishing transactions + * - return: array of unhandled transactions + */ + func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] +} + public enum TransactionResult { case purchased(product: Product) case restored(product: Product) case failed(error: Error) } +public struct RestorePurchases { + let atomically: Bool + let callback: ([TransactionResult]) -> () +} + public struct Payment: Hashable { public let product: SKProduct public let atomically: Bool @@ -29,10 +45,12 @@ public struct Payment: Hashable { } } -public class PaymentsController { +public class PaymentsController: TransactionController { private var payments: Set = [] + public init() { } + private func findPayment(withProductIdentifier identifier: String) -> Payment? { for payment in payments { if payment.product.productIdentifier == identifier { @@ -42,26 +60,63 @@ public class PaymentsController { return nil } + public func hasPayment(_ payment: Payment) -> Bool { + return findPayment(withProductIdentifier: payment.product.productIdentifier) != nil + } + public func insert(_ payment: Payment) { payments.insert(payment) } - public func processTransaction(_ transaction: SKPaymentTransaction, paymentQueue: PaymentQueue) -> Bool { + public func processTransaction(_ transaction: SKPaymentTransaction, on paymentQueue: PaymentQueue) -> Bool { let transactionProductIdentifier = transaction.payment.productIdentifier if let payment = findPayment(withProductIdentifier: transactionProductIdentifier) { + + let transactionState = transaction.transactionState - let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !payment.atomically) - - payment.callback(.purchased(product: product)) - - if payment.atomically { + if transactionState == .purchased { + + let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !payment.atomically) + + payment.callback(.purchased(product: product)) + + if payment.atomically { + paymentQueue.finishTransaction(transaction) + } + payments.remove(payment) + return true + } + if transactionState == .failed { + + let message = "Transaction failed for product ID: \(transactionProductIdentifier)" + let altError = NSError(domain: SKErrorDomain, code: 0, userInfo: [ NSLocalizedDescriptionKey: message ]) + payment.callback(.failed(error: transaction.error ?? altError)) + paymentQueue.finishTransaction(transaction) + payments.remove(payment) + return true + } + + if transactionState == .restored { + print("Unexpected restored transaction for payment \(transactionProductIdentifier)") } - payments.remove(payment) - return true } return false } + + public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + + return transactions.filter { !processTransaction($0, on: paymentQueue) } + } +} + +public class RestorePurchasesController: TransactionController { + + public var restorePurchases: RestorePurchases? + + public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + return [] + } } diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift index 51c2e7d9..7404418a 100644 --- a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -37,20 +37,6 @@ extension Payment { class PaymentQueueControllerTests: XCTestCase { - class TestProduct: SKProduct { - - var _productIdentifier: String = "" - - override var productIdentifier: String { - return _productIdentifier - } - - init(productIdentifier: String) { - _productIdentifier = productIdentifier - super.init() - } - } - // MARK: init/deinit func testInit_registersAsObserver() { diff --git a/SwiftyStoreKitTests/PaymentQueueSpy.swift b/SwiftyStoreKitTests/PaymentQueueSpy.swift index 6ee0228d..8d31b2df 100644 --- a/SwiftyStoreKitTests/PaymentQueueSpy.swift +++ b/SwiftyStoreKitTests/PaymentQueueSpy.swift @@ -16,6 +16,8 @@ class PaymentQueueSpy: PaymentQueue { var payments: [SKPayment] = [] var restoreCompletedTransactionCalledCount = 0 + + var finishTransactionCalledCount = 0 func add(_ observer: SKPaymentTransactionObserver) { @@ -40,5 +42,6 @@ class PaymentQueueSpy: PaymentQueue { func finishTransaction(_ transaction: SKPaymentTransaction) { + finishTransactionCalledCount += 1 } } diff --git a/SwiftyStoreKitTests/PaymentsControllerTests.swift b/SwiftyStoreKitTests/PaymentsControllerTests.swift new file mode 100644 index 00000000..ee0427c2 --- /dev/null +++ b/SwiftyStoreKitTests/PaymentsControllerTests.swift @@ -0,0 +1,98 @@ +// +// PaymentsControllerTests.swift +// SwiftyStoreKit +// +// 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 +import SwiftyStoreKit +import StoreKit + +class PaymentsControllerTests: XCTestCase { + + func testInsertPayment_hasPayment() { + + let payment = makeTestPayment(productIdentifier: "com.SwiftyStoreKit.product1") { result in } + + let paymentsController = makePaymentsController(insertPayment: payment) + + XCTAssertTrue(paymentsController.hasPayment(payment)) + } + + func testProcessTransaction_when_transactionStatePurchased_then_removesPayment_finishesTransaction_callsCallback() { + + let productIdentifier = "com.SwiftyStoreKit.product1" + let product = TestProduct(productIdentifier: productIdentifier) + + var callbackCalled = false + let payment = makeTestPayment(product: product) { result in + + callbackCalled = true + if case .purchased(let product) = result { + XCTAssertEqual(product.productId, productIdentifier) + } + else { + XCTFail("expected purchased callback with product id") + } + } + + let paymentsController = makePaymentsController(insertPayment: payment) + + let transaction = TestPaymentTransaction(payment: SKPayment(product: product), transactionState: .purchased) + + let spy = PaymentQueueSpy() + + let remainingTransactions = paymentsController.processTransactions([transaction], on: spy) + + XCTAssertEqual(remainingTransactions.count, 0) + + XCTAssertFalse(paymentsController.hasPayment(payment)) + + XCTAssertTrue(callbackCalled) + + XCTAssertEqual(spy.finishTransactionCalledCount, 1) + } + + func testProcessTransaction_when_transactionStateFailed_then_removesPayment_finishesTransaction_callsCallback() { + + } + + func makePaymentsController(insertPayment payment: Payment) -> PaymentsController { + + let paymentsController = PaymentsController() + + paymentsController.insert(payment) + + return paymentsController + } + + func makeTestPayment(product: SKProduct, atomically: Bool = true, callback: @escaping (TransactionResult) -> ()) -> Payment { + + return Payment(product: product, atomically: atomically, applicationUsername: "", callback: callback) + } + + func makeTestPayment(productIdentifier: String, atomically: Bool = true, callback: @escaping (TransactionResult) -> ()) -> Payment { + + let product = TestProduct(productIdentifier: productIdentifier) + return makeTestPayment(product: product, atomically: atomically, callback: callback) + + } +} diff --git a/SwiftyStoreKitTests/TestPaymentTransaction.swift b/SwiftyStoreKitTests/TestPaymentTransaction.swift new file mode 100644 index 00000000..788303b6 --- /dev/null +++ b/SwiftyStoreKitTests/TestPaymentTransaction.swift @@ -0,0 +1,44 @@ +// +// TestPaymentTransaction.swift +// SwiftyStoreKit +// +// 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 StoreKit + +class TestPaymentTransaction: SKPaymentTransaction { + + let _transactionState: SKPaymentTransactionState + let _payment: SKPayment + + init(payment: SKPayment, transactionState: SKPaymentTransactionState) { + _transactionState = transactionState + _payment = payment + } + + override var payment: SKPayment { + return _payment + } + + override var transactionState: SKPaymentTransactionState { + return _transactionState + } +} diff --git a/SwiftyStoreKitTests/TestProduct.swift b/SwiftyStoreKitTests/TestProduct.swift new file mode 100644 index 00000000..508e5ee7 --- /dev/null +++ b/SwiftyStoreKitTests/TestProduct.swift @@ -0,0 +1,39 @@ +// +// TestProduct.swift +// SwiftyStoreKit +// +// 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 StoreKit + +class TestProduct: SKProduct { + + var _productIdentifier: String = "" + + override var productIdentifier: String { + return _productIdentifier + } + + init(productIdentifier: String) { + _productIdentifier = productIdentifier + super.init() + } +} From 68532afc1191bf95a24166865b2eaa36ce0d253b Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 18 Jan 2017 20:46:52 +0000 Subject: [PATCH 04/31] Share SwiftyStoreKitTests target --- .../xcschemes/SwiftyStoreKitTests.xcscheme | 56 +++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKitTests.xcscheme diff --git a/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKitTests.xcscheme b/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKitTests.xcscheme new file mode 100644 index 00000000..f4eb55de --- /dev/null +++ b/SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKitTests.xcscheme @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist b/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist index 23f954a7..9389939a 100644 --- a/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist @@ -39,7 +39,7 @@ orderHint 1 - SwiftyStoreKitTests.xcscheme + SwiftyStoreKitTests.xcscheme_^#shared#^_ orderHint 5 From d8f444cee1f11d9b621adab7ae8c82c400741096 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Wed, 18 Jan 2017 22:50:06 +0000 Subject: [PATCH 05/31] Progress on restore purchases controller --- SwiftyStoreKit/PaymentQueueController.swift | 16 ++++++++++++++++ SwiftyStoreKit/Payments.swift | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 6d23ac2c..dd6d039b 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -92,6 +92,21 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { // MARK: SKPaymentTransactionObserver public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + /* + + The payment queue seems to process payments in-order, however any calls to restorePurchases can easily jump + ahead of the queue as the user flows for restorePurchases are simpler. + + SKPaymentQueue rejects multiple restorePurchases calls + + Having one payment queue observer for each request causes extra processing + + Can a failed translation ever belong to a restore purchases request? + No. restoreCompletedTransactionsFailedWithError is called instead. + + + + */ var unhandledTransactions = paymentsController.processTransactions(transactions, on: paymentQueue) unhandledTransactions = restorePurchasesController.processTransactions(unhandledTransactions, on: paymentQueue) @@ -129,6 +144,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { public func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { + restorePurchasesController.restoreCompletedTransactionsFailed(withError: error) } public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { diff --git a/SwiftyStoreKit/Payments.swift b/SwiftyStoreKit/Payments.swift index 8a308880..24b90b13 100644 --- a/SwiftyStoreKit/Payments.swift +++ b/SwiftyStoreKit/Payments.swift @@ -117,6 +117,19 @@ public class RestorePurchasesController: TransactionController { public var restorePurchases: RestorePurchases? public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + + guard let restorePurchases = restorePurchases else { + return transactions + } + // TODO: process return [] } + + public func restoreCompletedTransactionsFailed(withError error: Error) { + + guard let restorePurchases = restorePurchases else { + return + } + restorePurchases.callback([.failed(error: error)]) + } } From 517d7d690e9615816e7ccfd2c91064df74dd5191 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 21:11:37 +0000 Subject: [PATCH 06/31] Add purchase failed unit test --- .../PaymentsControllerTests.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/SwiftyStoreKitTests/PaymentsControllerTests.swift b/SwiftyStoreKitTests/PaymentsControllerTests.swift index ee0427c2..f3d32b77 100644 --- a/SwiftyStoreKitTests/PaymentsControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentsControllerTests.swift @@ -73,6 +73,36 @@ class PaymentsControllerTests: XCTestCase { func testProcessTransaction_when_transactionStateFailed_then_removesPayment_finishesTransaction_callsCallback() { + let productIdentifier = "com.SwiftyStoreKit.product1" + let product = TestProduct(productIdentifier: productIdentifier) + + var callbackCalled = false + let payment = makeTestPayment(product: product) { result in + + callbackCalled = true + if case .failed(_) = result { + + } + else { + XCTFail("expected failed callback with error") + } + } + + let paymentsController = makePaymentsController(insertPayment: payment) + + let transaction = TestPaymentTransaction(payment: SKPayment(product: product), transactionState: .failed) + + let spy = PaymentQueueSpy() + + let remainingTransactions = paymentsController.processTransactions([transaction], on: spy) + + XCTAssertEqual(remainingTransactions.count, 0) + + XCTAssertFalse(paymentsController.hasPayment(payment)) + + XCTAssertTrue(callbackCalled) + + XCTAssertEqual(spy.finishTransactionCalledCount, 1) } func makePaymentsController(insertPayment payment: Payment) -> PaymentsController { From 2adfadf856c7253a41bd3849ced60ce31dae6c29 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 22:40:21 +0000 Subject: [PATCH 07/31] Implemented RestorePurchasesController and tests --- SwiftyStoreKit.xcodeproj/project.pbxproj | 9 +- SwiftyStoreKit/Payments.swift | 96 +++++++++----- .../PaymentsControllerTests.swift | 12 +- .../RestorePurchasesControllerTests.swift | 122 ++++++++++++++++++ 4 files changed, 202 insertions(+), 37 deletions(-) create mode 100644 SwiftyStoreKitTests/RestorePurchasesControllerTests.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 5b12ba80..20822b61 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; }; 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; }; 6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; }; + 650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */; }; 651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; @@ -133,6 +134,7 @@ 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppProductQueryRequest.swift; sourceTree = ""; }; 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyStoreKit.swift; sourceTree = ""; }; 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesControllerTests.swift; sourceTree = ""; }; 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppCompleteTransactionsObserver.swift; sourceTree = ""; }; 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = ""; }; 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = ""; }; @@ -270,6 +272,7 @@ 658A08421E2EC5120074A98F /* Info.plist */, 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */, C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */, + 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */, 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */, 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, C3099C081E2FCE3A00392A54 /* TestProduct.swift */, @@ -473,6 +476,7 @@ }; 6502F5FD1B985833004E342D = { CreatedOnToolsVersion = 7.0; + DevelopmentTeam = M54ZVB688G; LastSwiftMigration = 0800; ProvisioningStyle = Automatic; }; @@ -621,6 +625,7 @@ buildActionMask = 2147483647; files = ( C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */, + 650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */, C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */, 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */, 658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */, @@ -854,7 +859,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"; @@ -870,7 +875,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/Payments.swift b/SwiftyStoreKit/Payments.swift index 24b90b13..b8d92a46 100644 --- a/SwiftyStoreKit/Payments.swift +++ b/SwiftyStoreKit/Payments.swift @@ -27,8 +27,13 @@ public enum TransactionResult { } public struct RestorePurchases { - let atomically: Bool - let callback: ([TransactionResult]) -> () + public let atomically: Bool + public let callback: ([TransactionResult]) -> () + + public init(atomically: Bool, callback: @escaping ([TransactionResult]) -> ()) { + self.atomically = atomically + self.callback = callback + } } public struct Payment: Hashable { @@ -72,36 +77,37 @@ public class PaymentsController: TransactionController { let transactionProductIdentifier = transaction.payment.productIdentifier - if let payment = findPayment(withProductIdentifier: transactionProductIdentifier) { + guard let payment = findPayment(withProductIdentifier: transactionProductIdentifier) else { - let transactionState = transaction.transactionState - - if transactionState == .purchased { - - let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !payment.atomically) - - payment.callback(.purchased(product: product)) - - if payment.atomically { - paymentQueue.finishTransaction(transaction) - } - payments.remove(payment) - return true - } - if transactionState == .failed { + return false + } + let transactionState = transaction.transactionState + + if transactionState == .purchased { - let message = "Transaction failed for product ID: \(transactionProductIdentifier)" - let altError = NSError(domain: SKErrorDomain, code: 0, userInfo: [ NSLocalizedDescriptionKey: message ]) - payment.callback(.failed(error: transaction.error ?? altError)) - + let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !payment.atomically) + + payment.callback(.purchased(product: product)) + + if payment.atomically { paymentQueue.finishTransaction(transaction) - payments.remove(payment) - return true } + payments.remove(payment) + return true + } + if transactionState == .failed { + + let message = "Transaction failed for product ID: \(transactionProductIdentifier)" + let altError = NSError(domain: SKErrorDomain, code: 0, userInfo: [ NSLocalizedDescriptionKey: message ]) + payment.callback(.failed(error: transaction.error ?? altError)) - if transactionState == .restored { - print("Unexpected restored transaction for payment \(transactionProductIdentifier)") - } + paymentQueue.finishTransaction(transaction) + payments.remove(payment) + return true + } + + if transactionState == .restored { + print("Unexpected restored transaction for payment \(transactionProductIdentifier)") } return false } @@ -116,13 +122,45 @@ public class RestorePurchasesController: TransactionController { public var restorePurchases: RestorePurchases? + public init() { } + + public func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? { + + let transactionState = transaction.transactionState + + if transactionState == .restored { + + let transactionProductIdentifier = transaction.payment.productIdentifier + + let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically) + if atomically { + paymentQueue.finishTransaction(transaction) + } + return product + } + return nil + } + public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { guard let restorePurchases = restorePurchases else { return transactions } - // TODO: process - return [] + + var unhandledTransactions: [SKPaymentTransaction] = [] + var restoredProducts: [TransactionResult] = [] + for transaction in transactions { + if let restoredProduct = processTransaction(transaction, atomically: restorePurchases.atomically, on: paymentQueue) { + restoredProducts.append(.restored(product: restoredProduct)) + } + else { + unhandledTransactions.append(transaction) + } + } + if restoredProducts.count > 0 { + restorePurchases.callback(restoredProducts) + } + return unhandledTransactions } public func restoreCompletedTransactionsFailed(withError error: Error) { diff --git a/SwiftyStoreKitTests/PaymentsControllerTests.swift b/SwiftyStoreKitTests/PaymentsControllerTests.swift index f3d32b77..841d2257 100644 --- a/SwiftyStoreKitTests/PaymentsControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentsControllerTests.swift @@ -40,10 +40,10 @@ class PaymentsControllerTests: XCTestCase { func testProcessTransaction_when_transactionStatePurchased_then_removesPayment_finishesTransaction_callsCallback() { let productIdentifier = "com.SwiftyStoreKit.product1" - let product = TestProduct(productIdentifier: productIdentifier) + let testProduct = TestProduct(productIdentifier: productIdentifier) var callbackCalled = false - let payment = makeTestPayment(product: product) { result in + let payment = makeTestPayment(product: testProduct) { result in callbackCalled = true if case .purchased(let product) = result { @@ -56,7 +56,7 @@ class PaymentsControllerTests: XCTestCase { let paymentsController = makePaymentsController(insertPayment: payment) - let transaction = TestPaymentTransaction(payment: SKPayment(product: product), transactionState: .purchased) + let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .purchased) let spy = PaymentQueueSpy() @@ -74,10 +74,10 @@ class PaymentsControllerTests: XCTestCase { func testProcessTransaction_when_transactionStateFailed_then_removesPayment_finishesTransaction_callsCallback() { let productIdentifier = "com.SwiftyStoreKit.product1" - let product = TestProduct(productIdentifier: productIdentifier) + let testProduct = TestProduct(productIdentifier: productIdentifier) var callbackCalled = false - let payment = makeTestPayment(product: product) { result in + let payment = makeTestPayment(product: testProduct) { result in callbackCalled = true if case .failed(_) = result { @@ -90,7 +90,7 @@ class PaymentsControllerTests: XCTestCase { let paymentsController = makePaymentsController(insertPayment: payment) - let transaction = TestPaymentTransaction(payment: SKPayment(product: product), transactionState: .failed) + let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .failed) let spy = PaymentQueueSpy() diff --git a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift new file mode 100644 index 00000000..631697f2 --- /dev/null +++ b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift @@ -0,0 +1,122 @@ +// +// RestorePurchasesControllerTests.swift +// SwiftyStoreKit +// +// 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 +import SwiftyStoreKit +import StoreKit + +class RestorePurchasesControllerTests: XCTestCase { + + + func testProcessTransactions_when_oneRestoredTransaction_then_finishesTransaction_callsCallback_noRemainingTransactions() { + + let productIdentifier = "com.SwiftyStoreKit.product1" + let testProduct = TestProduct(productIdentifier: productIdentifier) + + let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .restored) + + var callbackCalled = false + let restorePurchases = RestorePurchases(atomically: true) { results in + callbackCalled = true + XCTAssertEqual(results.count, 1) + let restored = results.first! + if case .restored(let restoredProduct) = restored { + XCTAssertEqual(restoredProduct.productId, productIdentifier) + } + else { + XCTFail("expected restored callback with product") + } + } + + let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases) + + let spy = PaymentQueueSpy() + + let remainingTransactions = restorePurchasesController.processTransactions([transaction], on: spy) + + XCTAssertEqual(remainingTransactions.count, 0) + + XCTAssertTrue(callbackCalled) + + XCTAssertEqual(spy.finishTransactionCalledCount, 1) + } + + func testProcessTransactions_when_twoRestoredTransactions_oneFailedTransaction_onePurchasedTransaction_then_finishesTwoTransactions_callsCallback_twoRemainingTransaction() { + + let productIdentifier1 = "com.SwiftyStoreKit.product1" + let testProduct1 = TestProduct(productIdentifier: productIdentifier1) + let transaction1 = TestPaymentTransaction(payment: SKPayment(product: testProduct1), transactionState: .restored) + + let productIdentifier2 = "com.SwiftyStoreKit.product2" + let testProduct2 = TestProduct(productIdentifier: productIdentifier2) + let transaction2 = TestPaymentTransaction(payment: SKPayment(product: testProduct2), transactionState: .restored) + + let productIdentifier3 = "com.SwiftyStoreKit.product3" + let testProduct3 = TestProduct(productIdentifier: productIdentifier3) + let transaction3 = TestPaymentTransaction(payment: SKPayment(product: testProduct3), transactionState: .failed) + + let productIdentifier4 = "com.SwiftyStoreKit.product4" + let testProduct4 = TestProduct(productIdentifier: productIdentifier4) + let transaction4 = TestPaymentTransaction(payment: SKPayment(product: testProduct4), transactionState: .purchased) + + let transactions = [transaction1, transaction2, transaction3, transaction4] + + var callbackCalled = false + let restorePurchases = RestorePurchases(atomically: true) { results in + callbackCalled = true + XCTAssertEqual(results.count, 2) + let first = results.first! + let last = results.last! + if case .restored(let restoredProduct) = restored { + XCTAssertEqual(restoredProduct.productId, productIdentifier1) + } + else { + XCTFail("expected restored callback with product") + } + } + + let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases) + + let spy = PaymentQueueSpy() + + let remainingTransactions = restorePurchasesController.processTransactions(transactions, on: spy) + + XCTAssertEqual(remainingTransactions.count, 2) + + XCTAssertTrue(callbackCalled) + + XCTAssertEqual(spy.finishTransactionCalledCount, 1) + } + + + func makeRestorePurchasesController(restorePurchases: RestorePurchases?) -> RestorePurchasesController { + + let restorePurchasesController = RestorePurchasesController() + + restorePurchasesController.restorePurchases = restorePurchases + + return restorePurchasesController + } +} From c0749d206c68bfc20768f3acaf852baeb65358b2 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 22:44:20 +0000 Subject: [PATCH 08/31] Cleanup --- SwiftyStoreKit.xcodeproj/project.pbxproj | 24 ++-- SwiftyStoreKit/PaymentQueueController.swift | 24 ++-- ...ayments.swift => PaymentsController.swift} | 105 ++++-------------- .../RestorePurchasesController.swift | 91 +++++++++++++++ .../RestorePurchasesControllerTests.swift | 13 ++- 5 files changed, 154 insertions(+), 103 deletions(-) rename SwiftyStoreKit/{Payments.swift => PaymentsController.swift} (50%) create mode 100644 SwiftyStoreKit/RestorePurchasesController.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 20822b61..88d9bc6d 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; }; 6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; }; 650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */; }; + 650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; }; + 650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; }; + 650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; }; 651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; @@ -36,9 +39,9 @@ 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 */; }; 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */; }; - 65F70AC91E2EDC3700BF040D /* Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* Payments.swift */; }; - 65F70ACA1E2EDC3700BF040D /* Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* Payments.swift */; }; - 65F70ACB1E2EDC3700BF040D /* Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* Payments.swift */; }; + 65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; }; + 65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; }; + 65F70ACB1E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; }; 65F7DF711DCD4DF000835D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */; }; 65F7DF721DCD4DF000835D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF691DCD4DF000835D30 /* Assets.xcassets */; }; 65F7DF731DCD4DF000835D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF6A1DCD4DF000835D30 /* LaunchScreen.storyboard */; }; @@ -135,6 +138,7 @@ 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyStoreKit.swift; sourceTree = ""; }; 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesControllerTests.swift; sourceTree = ""; }; + 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesController.swift; sourceTree = ""; }; 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppCompleteTransactionsObserver.swift; sourceTree = ""; }; 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = ""; }; 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = ""; }; @@ -144,7 +148,7 @@ 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = ""; }; 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = ""; }; 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = ""; }; - 65F70AC81E2EDC3700BF040D /* Payments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Payments.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 = ""; }; 65F7DF691DCD4DF000835D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 65F7DF6B1DCD4DF000835D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -261,7 +265,8 @@ C40C680F1C29414C00B60B7E /* OS.swift */, 65F7DF931DCD536100835D30 /* Platforms */, 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, - 65F70AC81E2EDC3700BF040D /* Payments.swift */, + 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */, + 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */, ); path = SwiftyStoreKit; sourceTree = ""; @@ -582,7 +587,8 @@ 54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */, 54B069961CF744DC00BAFE38 /* OS.swift in Sources */, 54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */, - 65F70ACB1E2EDC3700BF040D /* Payments.swift in Sources */, + 65F70ACB1E2EDC3700BF040D /* PaymentsController.swift in Sources */, + 650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, 54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */, @@ -611,7 +617,8 @@ 6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */, 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */, C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */, - 65F70AC91E2EDC3700BF040D /* Payments.swift in Sources */, + 65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */, + 650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */, @@ -644,7 +651,8 @@ C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */, C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */, C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */, - 65F70ACA1E2EDC3700BF040D /* Payments.swift in Sources */, + 65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */, + 650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */, diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index dd6d039b..dbd21e6e 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -25,6 +25,23 @@ import Foundation import StoreKit + +public protocol TransactionController { + + /** + * - param transactions: transactions to process + * - param paymentQueue: payment queue for finishing transactions + * - return: array of unhandled transactions + */ + func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] +} + +public enum TransactionResult { + case purchased(product: Product) + case restored(product: Product) + case failed(error: Error) +} + public protocol PaymentQueue: class { func add(_ observer: SKPaymentTransactionObserver) @@ -156,10 +173,3 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { } } - -/* - If more than one payment is queued for a given product Id, - only the first callback should be called to ensure the content is delivered only once - - - */ diff --git a/SwiftyStoreKit/Payments.swift b/SwiftyStoreKit/PaymentsController.swift similarity index 50% rename from SwiftyStoreKit/Payments.swift rename to SwiftyStoreKit/PaymentsController.swift index b8d92a46..611589d7 100644 --- a/SwiftyStoreKit/Payments.swift +++ b/SwiftyStoreKit/PaymentsController.swift @@ -1,41 +1,31 @@ // -// Payments.swift -// SwiftyStoreKit +// PaymentsController.swift +// SwiftyStoreKit // -// Created by Andrea Bizzotto on 17/01/2017. -// Copyright © 2017 musevisions. All rights reserved. +// 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 import StoreKit - -public protocol TransactionController { - - /** - * - param transactions: transactions to process - * - param paymentQueue: payment queue for finishing transactions - * - return: array of unhandled transactions - */ - func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] -} - -public enum TransactionResult { - case purchased(product: Product) - case restored(product: Product) - case failed(error: Error) -} - -public struct RestorePurchases { - public let atomically: Bool - public let callback: ([TransactionResult]) -> () - - public init(atomically: Bool, callback: @escaping ([TransactionResult]) -> ()) { - self.atomically = atomically - self.callback = callback - } -} - public struct Payment: Hashable { public let product: SKProduct public let atomically: Bool @@ -118,56 +108,3 @@ public class PaymentsController: TransactionController { } } -public class RestorePurchasesController: TransactionController { - - public var restorePurchases: RestorePurchases? - - public init() { } - - public func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? { - - let transactionState = transaction.transactionState - - if transactionState == .restored { - - let transactionProductIdentifier = transaction.payment.productIdentifier - - let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically) - if atomically { - paymentQueue.finishTransaction(transaction) - } - return product - } - return nil - } - - public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { - - guard let restorePurchases = restorePurchases else { - return transactions - } - - var unhandledTransactions: [SKPaymentTransaction] = [] - var restoredProducts: [TransactionResult] = [] - for transaction in transactions { - if let restoredProduct = processTransaction(transaction, atomically: restorePurchases.atomically, on: paymentQueue) { - restoredProducts.append(.restored(product: restoredProduct)) - } - else { - unhandledTransactions.append(transaction) - } - } - if restoredProducts.count > 0 { - restorePurchases.callback(restoredProducts) - } - return unhandledTransactions - } - - public func restoreCompletedTransactionsFailed(withError error: Error) { - - guard let restorePurchases = restorePurchases else { - return - } - restorePurchases.callback([.failed(error: error)]) - } -} diff --git a/SwiftyStoreKit/RestorePurchasesController.swift b/SwiftyStoreKit/RestorePurchasesController.swift new file mode 100644 index 00000000..fdbd5bff --- /dev/null +++ b/SwiftyStoreKit/RestorePurchasesController.swift @@ -0,0 +1,91 @@ +// +// RestorePurchasesController.swift +// SwiftyStoreKit +// +// 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 +import StoreKit + +public struct RestorePurchases { + public let atomically: Bool + public let callback: ([TransactionResult]) -> () + + public init(atomically: Bool, callback: @escaping ([TransactionResult]) -> ()) { + self.atomically = atomically + self.callback = callback + } +} + + +public class RestorePurchasesController: TransactionController { + + public var restorePurchases: RestorePurchases? + + public init() { } + + public func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? { + + let transactionState = transaction.transactionState + + if transactionState == .restored { + + let transactionProductIdentifier = transaction.payment.productIdentifier + + let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically) + if atomically { + paymentQueue.finishTransaction(transaction) + } + return product + } + return nil + } + + public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + + guard let restorePurchases = restorePurchases else { + return transactions + } + + var unhandledTransactions: [SKPaymentTransaction] = [] + var restoredProducts: [TransactionResult] = [] + for transaction in transactions { + if let restoredProduct = processTransaction(transaction, atomically: restorePurchases.atomically, on: paymentQueue) { + restoredProducts.append(.restored(product: restoredProduct)) + } + else { + unhandledTransactions.append(transaction) + } + } + if restoredProducts.count > 0 { + restorePurchases.callback(restoredProducts) + } + return unhandledTransactions + } + + public func restoreCompletedTransactionsFailed(withError error: Error) { + + guard let restorePurchases = restorePurchases else { + return + } + restorePurchases.callback([.failed(error: error)]) + } +} diff --git a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift index 631697f2..c7165a23 100644 --- a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift +++ b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift @@ -29,7 +29,6 @@ import StoreKit class RestorePurchasesControllerTests: XCTestCase { - func testProcessTransactions_when_oneRestoredTransaction_then_finishesTransaction_callsCallback_noRemainingTransactions() { let productIdentifier = "com.SwiftyStoreKit.product1" @@ -88,13 +87,19 @@ class RestorePurchasesControllerTests: XCTestCase { callbackCalled = true XCTAssertEqual(results.count, 2) let first = results.first! - let last = results.last! - if case .restored(let restoredProduct) = restored { + if case .restored(let restoredProduct) = first { XCTAssertEqual(restoredProduct.productId, productIdentifier1) } else { XCTFail("expected restored callback with product") } + let last = results.last! + if case .restored(let restoredProduct) = last { + XCTAssertEqual(restoredProduct.productId, productIdentifier2) + } + else { + XCTFail("expected restored callback with product") + } } let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases) @@ -107,7 +112,7 @@ class RestorePurchasesControllerTests: XCTestCase { XCTAssertTrue(callbackCalled) - XCTAssertEqual(spy.finishTransactionCalledCount, 1) + XCTAssertEqual(spy.finishTransactionCalledCount, 2) } From f2e371bf759f9ef67793b5c492e8cd98defa46d9 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 22:49:03 +0000 Subject: [PATCH 09/31] Add restoreCompletedTransactionsFailed unit test --- .../RestorePurchasesControllerTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift index c7165a23..1f774297 100644 --- a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift +++ b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift @@ -114,6 +114,31 @@ class RestorePurchasesControllerTests: XCTestCase { XCTAssertEqual(spy.finishTransactionCalledCount, 2) } + + func testRestoreCompletedTransactionsFailed_callsCallbackWithError() { + + var callbackCalled = false + let restorePurchases = RestorePurchases(atomically: true) { results in + callbackCalled = true + + XCTAssertEqual(results.count, 1) + let first = results.first! + if case .failed(_) = first { + + } + else { + XCTFail("expected failed callback with error") + } + } + + let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases) + + let error = NSError(domain: "SwiftyStoreKit", code: 0, userInfo: nil) + + restorePurchasesController.restoreCompletedTransactionsFailed(withError: error) + + XCTAssertTrue(callbackCalled) + } func makeRestorePurchasesController(restorePurchases: RestorePurchases?) -> RestorePurchasesController { From 91481dff13f1bb7cef8b7e6d6e6b10411ec1a1bb Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 22:56:01 +0000 Subject: [PATCH 10/31] Add restoreCompletedTransactionsFinished and unit test --- SwiftyStoreKit/PaymentQueueController.swift | 1 + .../RestorePurchasesController.swift | 18 ++++++++++++++++++ .../RestorePurchasesControllerTests.swift | 14 ++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index dbd21e6e..307c25d4 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -166,6 +166,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { + restorePurchasesController.restoreCompletedTransactionsFinished() } public func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) { diff --git a/SwiftyStoreKit/RestorePurchasesController.swift b/SwiftyStoreKit/RestorePurchasesController.swift index fdbd5bff..0a4d3054 100644 --- a/SwiftyStoreKit/RestorePurchasesController.swift +++ b/SwiftyStoreKit/RestorePurchasesController.swift @@ -78,6 +78,9 @@ public class RestorePurchasesController: TransactionController { if restoredProducts.count > 0 { restorePurchases.callback(restoredProducts) } + // Reset to nil after purchases complete + self.restorePurchases = nil + return unhandledTransactions } @@ -87,5 +90,20 @@ public class RestorePurchasesController: TransactionController { return } restorePurchases.callback([.failed(error: error)]) + + // Reset to nil after error received + self.restorePurchases = nil + + } + + public func restoreCompletedTransactionsFinished() { + + guard let restorePurchases = restorePurchases else { + return + } + restorePurchases.callback([]) + + // Reset to nil after error transactions finished + self.restorePurchases = nil } } diff --git a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift index 1f774297..5d436775 100644 --- a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift +++ b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift @@ -140,6 +140,20 @@ class RestorePurchasesControllerTests: XCTestCase { XCTAssertTrue(callbackCalled) } + func testRestoreCompletedTransactionsFinished_callsCallbackWithNoTransactions() { + + var callbackCalled = false + let restorePurchases = RestorePurchases(atomically: true) { results in + callbackCalled = true + + XCTAssertEqual(results.count, 0) + } + let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases) + + restorePurchasesController.restoreCompletedTransactionsFinished() + + XCTAssertTrue(callbackCalled) + } func makeRestorePurchasesController(restorePurchases: RestorePurchases?) -> RestorePurchasesController { From b285bc14418497c654ad2dd97e98871757cbce06 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 23:02:00 +0000 Subject: [PATCH 11/31] Add CompleteTransactionsController definition --- SwiftyStoreKit.xcodeproj/project.pbxproj | 8 +++ .../CompleteTransactionsController.swift | 56 +++++++++++++++++++ SwiftyStoreKit/PaymentQueueController.swift | 36 +++--------- 3 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 SwiftyStoreKit/CompleteTransactionsController.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 88d9bc6d..7e6bb01f 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -24,6 +24,9 @@ 650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; }; 650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; }; 650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; }; + 650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; + 650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; + 650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; 651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; @@ -139,6 +142,7 @@ 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesControllerTests.swift; sourceTree = ""; }; 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesController.swift; sourceTree = ""; }; + 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompleteTransactionsController.swift; sourceTree = ""; }; 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppCompleteTransactionsObserver.swift; sourceTree = ""; }; 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = ""; }; 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = ""; }; @@ -267,6 +271,7 @@ 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */, 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */, + 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */, ); path = SwiftyStoreKit; sourceTree = ""; @@ -592,6 +597,7 @@ 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, 54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */, + 650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */, 65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */, 54B069941CF742D600BAFE38 /* InAppProductQueryRequest.swift in Sources */, ); @@ -622,6 +628,7 @@ 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */, + 650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */, 65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */, 6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */, ); @@ -656,6 +663,7 @@ 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */, + 650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */, 65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */, C4D74BC51C24CEDC0071AD3E /* SwiftyStoreKit.swift in Sources */, ); diff --git a/SwiftyStoreKit/CompleteTransactionsController.swift b/SwiftyStoreKit/CompleteTransactionsController.swift new file mode 100644 index 00000000..a89ec7e0 --- /dev/null +++ b/SwiftyStoreKit/CompleteTransactionsController.swift @@ -0,0 +1,56 @@ +// +// CompleteTransactionsController.swift +// SwiftyStoreKit +// +// 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 +import StoreKit + +public class CompleteTransactionsController: TransactionController { + + public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + + for transaction in transactions { + + let transactionState = transaction.transactionState + + switch transactionState { + case .purchased: + + break + case .failed: + break + + case .restored: + break + + case .purchasing: + // In progress: do nothing + break + case .deferred: + break + } + } + + return transactions + } +} diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 307c25d4..d9c23d24 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -62,6 +62,8 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { private let restorePurchasesController: RestorePurchasesController + private let completeTransactionsController: CompleteTransactionsController + unowned let paymentQueue: PaymentQueue deinit { @@ -70,11 +72,13 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { public init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), paymentsController: PaymentsController = PaymentsController(), - restorePurchasesController: RestorePurchasesController = RestorePurchasesController()) { + restorePurchasesController: RestorePurchasesController = RestorePurchasesController(), + completeTransactionsController: CompleteTransactionsController = CompleteTransactionsController()) { self.paymentQueue = paymentQueue self.paymentsController = paymentsController self.restorePurchasesController = restorePurchasesController + self.completeTransactionsController = completeTransactionsController super.init() paymentQueue.add(self) } @@ -121,38 +125,16 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { Can a failed translation ever belong to a restore purchases request? No. restoreCompletedTransactionsFailedWithError is called instead. - - */ var unhandledTransactions = paymentsController.processTransactions(transactions, on: paymentQueue) unhandledTransactions = restorePurchasesController.processTransactions(unhandledTransactions, on: paymentQueue) - - // TODO: Complete transactions -// for transaction in transactionsExcludingPayments { -// -// let transactionState = transaction.transactionState -// -// switch transactionState { -// case .purchased: -// -// break -// case .failed: -// break -// -// case .restored: -// break -// -// case .purchasing: -// // In progress: do nothing -// break -// case .deferred: -// break -// } -// -// } + unhandledTransactions = completeTransactionsController.processTransactions(unhandledTransactions, on: paymentQueue) + if unhandledTransactions.count > 0 { + print("unhandledTransactions: \(unhandledTransactions)") + } } public func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { From 4eca383348675ebab8a6e0e49a75d6967614e353 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 23:12:50 +0000 Subject: [PATCH 12/31] Wiring up CompleteTransactionsController --- .../CompleteTransactionsController.swift | 57 ++++++++++++------- SwiftyStoreKit/PaymentQueueController.swift | 5 ++ 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/SwiftyStoreKit/CompleteTransactionsController.swift b/SwiftyStoreKit/CompleteTransactionsController.swift index a89ec7e0..1e93a5b0 100644 --- a/SwiftyStoreKit/CompleteTransactionsController.swift +++ b/SwiftyStoreKit/CompleteTransactionsController.swift @@ -25,32 +25,51 @@ import Foundation import StoreKit +public struct CompleteTransactions { + public let atomically: Bool + public let callback: ([Product]) -> () + + public init(atomically: Bool, callback: @escaping ([Product]) -> ()) { + self.atomically = atomically + self.callback = callback + } +} + public class CompleteTransactionsController: TransactionController { + public var completeTransactions: CompleteTransactions? + public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { - - for transaction in transactions { + + guard let completeTransactions = completeTransactions else { + return transactions + } + var unhandledTransactions: [SKPaymentTransaction] = [] + var products: [Product] = [] + + for transaction in transactions { + let transactionState = transaction.transactionState - - switch transactionState { - case .purchased: - - break - case .failed: - break - - case .restored: - break - - case .purchasing: - // In progress: do nothing - break - case .deferred: - break + + if transactionState != .purchasing { + + let product = Product(productId: transaction.payment.productIdentifier, transaction: transaction, needsFinishTransaction: !completeTransactions.atomically) + + products.append(product) + + print("Finishing transaction for payment \"\(transaction.payment.productIdentifier)\" with state: \(transactionState.stringValue)") + + if completeTransactions.atomically { + paymentQueue.finishTransaction(transaction) + } + } + else { + unhandledTransactions.append(transaction) } } + completeTransactions.callback(products) - return transactions + return unhandledTransactions } } diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index d9c23d24..80d6fe16 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -109,6 +109,11 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { restorePurchasesController.restorePurchases = restorePurchases } + public func completeTransactions(_ completeTransactions: CompleteTransactions) { + + completeTransactionsController.completeTransactions = completeTransactions + } + // MARK: SKPaymentTransactionObserver public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { From 9ec02899cf2d8e24fbcacd84fb2ecbeddd38cf56 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Thu, 19 Jan 2017 23:17:38 +0000 Subject: [PATCH 13/31] Preparing to hook PaymentQueueController in SwiftyStoreKit --- SwiftyStoreKit/PaymentQueueController.swift | 8 ++++++++ SwiftyStoreKit/SwiftyStoreKit.swift | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 80d6fe16..749e31fb 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -114,6 +114,14 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { completeTransactionsController.completeTransactions = completeTransactions } + public func finishTransaction(_ transaction: PaymentTransaction) { + guard let skTransaction = transaction as? SKPaymentTransaction else { + print("Object is not a SKPaymentTransaction: \(transaction)") + return + } + paymentQueue.finishTransaction(skTransaction) + } + // MARK: SKPaymentTransactionObserver public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index 03403508..71a5ec9c 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -47,6 +47,8 @@ public class SwiftyStoreKit { // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id private var inflightQueries: [Set: InAppProductQueryRequest] = [:] + private var paymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()) + private var inflightPurchases: [String: InAppProductPurchaseRequest] = [:] private var restoreRequest: InAppProductPurchaseRequest? private var completeTransactionsObserver: InAppCompleteTransactionsObserver? @@ -112,6 +114,8 @@ public class SwiftyStoreKit { public class func restorePurchases(atomically: Bool = true, completion: @escaping (RestoreResults) -> ()) { + // TODO: paymentQueueController.restorePurchases + sharedInstance.restoreRequest = InAppProductPurchaseRequest.restorePurchases(atomically: atomically) { results in sharedInstance.restoreRequest = nil @@ -207,6 +211,8 @@ public class SwiftyStoreKit { completion(.error(error: .paymentNotAllowed)) return } + + // TODO: paymentQueueController.startPayment inflightPurchases[product.productIdentifier] = InAppProductPurchaseRequest.startPayment(product: product, atomically: atomically, applicationUsername: applicationUsername) { results in From 63bab1d2c59d87606fc219d25beab10d74cdaac0 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 20 Jan 2017 20:34:39 +0000 Subject: [PATCH 14/31] Added CompleteTransactionsControllerTests --- .../CompleteTransactionsController.swift | 16 +++ .../CompleteTransactionsControllerTests.swift | 107 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift diff --git a/SwiftyStoreKit/CompleteTransactionsController.swift b/SwiftyStoreKit/CompleteTransactionsController.swift index 1e93a5b0..86039796 100644 --- a/SwiftyStoreKit/CompleteTransactionsController.swift +++ b/SwiftyStoreKit/CompleteTransactionsController.swift @@ -35,9 +35,25 @@ public struct CompleteTransactions { } } +extension SKPaymentTransactionState { + + var stringValue: String { + switch self { + case .purchasing: return "purchasing" + case .purchased: return "purchased" + case .failed: return "failed" + case .restored: return "restored" + case .deferred: return "deferred" + } + } +} + + public class CompleteTransactionsController: TransactionController { public var completeTransactions: CompleteTransactions? + + public init() {} public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { diff --git a/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift b/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift new file mode 100644 index 00000000..30b30d4f --- /dev/null +++ b/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift @@ -0,0 +1,107 @@ +// +// CompleteTransactionsControllerTests.swift +// SwiftyStoreKit +// +// 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 +import SwiftyStoreKit +import StoreKit + +class CompleteTransactionsControllerTests: XCTestCase { + + func testProcessTransactions_when_oneRestoredTransaction_then_finishesTransaction_callsCallback_noRemainingTransactions() { + + let productIdentifier = "com.SwiftyStoreKit.product1" + let testProduct = TestProduct(productIdentifier: productIdentifier) + + let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .restored) + + var callbackCalled = false + let restorePurchases = CompleteTransactions(atomically: true) { products in + callbackCalled = true + XCTAssertEqual(products.count, 1) + let product = products.first! + XCTAssertEqual(product.productId, productIdentifier) + } + + let completeTransactionsController = makeCompleteTransactionsController(completeTransactions: restorePurchases) + + let spy = PaymentQueueSpy() + + let remainingTransactions = completeTransactionsController.processTransactions([transaction], on: spy) + + XCTAssertEqual(remainingTransactions.count, 0) + + XCTAssertTrue(callbackCalled) + + XCTAssertEqual(spy.finishTransactionCalledCount, 1) + } + + func testProcessTransactions_when_oneTransactionForEachState_then_finishesTransactions_callsCallback_onePurchasingTransactionRemaining() { + + let transactions = [ + makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product1", transactionState: .purchased), + makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product2", transactionState: .failed), + makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product3", transactionState: .restored), + makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product4", transactionState: .deferred), + makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product5", transactionState: .purchasing), + ] + + var callbackCalled = false + let restorePurchases = CompleteTransactions(atomically: true) { products in + callbackCalled = true + XCTAssertEqual(products.count, 4) + + for i in 0..<4 { + XCTAssertEqual(products[i].productId, transactions[i].payment.productIdentifier) + } + } + + let completeTransactionsController = makeCompleteTransactionsController(completeTransactions: restorePurchases) + + let spy = PaymentQueueSpy() + + let remainingTransactions = completeTransactionsController.processTransactions(transactions, on: spy) + + XCTAssertEqual(remainingTransactions.count, 1) + + XCTAssertTrue(callbackCalled) + + XCTAssertEqual(spy.finishTransactionCalledCount, 4) + } + + func makeTestPaymentTransaction(productIdentifier: String, transactionState: SKPaymentTransactionState) -> TestPaymentTransaction { + + let testProduct = TestProduct(productIdentifier: productIdentifier) + return TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: transactionState) + } + + func makeCompleteTransactionsController(completeTransactions: CompleteTransactions?) -> CompleteTransactionsController { + + let completeTransactionsController = CompleteTransactionsController() + + completeTransactionsController.completeTransactions = completeTransactions + + return completeTransactionsController + } + +} From 3616668cfc726f358c91cc95c9f2c224d21fbfbe Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 20 Jan 2017 20:36:04 +0000 Subject: [PATCH 15/31] Added PaymentQueueController integration tests. These are useful as reference and documentation about the order in which transactions are processed. --- SwiftyStoreKit/PaymentQueueController.swift | 2 +- .../PaymentQueueControllerTests.swift | 87 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 749e31fb..cb8d2796 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -97,7 +97,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { paymentsController.insert(payment) } - public func startRestorePurchases(_ restorePurchases: RestorePurchases) { + public func restorePurchases(_ restorePurchases: RestorePurchases) { if restorePurchasesController.restorePurchases != nil { // return .inProgress diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift index 7404418a..505ecf33 100644 --- a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -64,11 +64,94 @@ class PaymentQueueControllerTests: XCTestCase { let paymentQueueController = PaymentQueueController(paymentQueue: spy) - let product = TestProduct(productIdentifier: "com.SwiftyStoreKit.product1") - let payment = Payment(product: product, atomically: true, applicationUsername: "", callback: { result in }) + let payment = makeTestPayment(productIdentifier: "com.SwiftyStoreKit.product1") { result in } paymentQueueController.startPayment(payment) XCTAssertEqual(spy.payments.count, 1) } + + // MARK: SKPaymentTransactionObserver callbacks + func testPaymentQueue_when_oneTransactionForEachState_then_correctCallbacksCalled() { + + // setup + let spy = PaymentQueueSpy() + + let paymentQueueController = PaymentQueueController(paymentQueue: spy) + + let purchasedProductIdentifier = "com.SwiftyStoreKit.product1" + let failedProductIdentifier = "com.SwiftyStoreKit.product2" + let restoredProductIdentifier = "com.SwiftyStoreKit.product3" + let deferredProductIdentifier = "com.SwiftyStoreKit.product4" + let purchasingProductIdentifier = "com.SwiftyStoreKit.product5" + + let transactions = [ + makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased), + makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed), + makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored), + makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred), + makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing), + ] + + + var paymentCallbackCalled = false + let testPayment = makeTestPayment(productIdentifier: purchasedProductIdentifier) { result in + paymentCallbackCalled = true + if case .purchased(let product) = result { + XCTAssertEqual(product.productId, purchasedProductIdentifier) + } + else { + XCTFail("expected purchased callback with product id") + } + } + + var restorePurchasesCallbackCalled = false + let restorePurchases = RestorePurchases(atomically: true) { results in + restorePurchasesCallbackCalled = true + XCTAssertEqual(results.count, 1) + let first = results.first! + if case .restored(let restoredProduct) = first { + XCTAssertEqual(restoredProduct.productId, restoredProductIdentifier) + } + else { + XCTFail("expected restored callback with product") + } + } + + var completeTransactionsCallbackCalled = false + let completeTransactions = CompleteTransactions(atomically: true) { products in + completeTransactionsCallbackCalled = true + XCTAssertEqual(products.count, 2) + XCTAssertEqual(products[0].productId, failedProductIdentifier) + XCTAssertEqual(products[1].productId, deferredProductIdentifier) + } + + // run + paymentQueueController.startPayment(testPayment) + + paymentQueueController.restorePurchases(restorePurchases) + + paymentQueueController.completeTransactions(completeTransactions) + + paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions) + + // verify + XCTAssertTrue(paymentCallbackCalled) + XCTAssertTrue(restorePurchasesCallbackCalled) + XCTAssertTrue(completeTransactionsCallbackCalled) + } + + + // MARK: Helpers + func makeTestPaymentTransaction(productIdentifier: String, transactionState: SKPaymentTransactionState) -> TestPaymentTransaction { + + let testProduct = TestProduct(productIdentifier: productIdentifier) + return TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: transactionState) + } + + func makeTestPayment(productIdentifier: String, atomically: Bool = true, callback: @escaping (TransactionResult) -> ()) -> Payment { + + let testProduct = TestProduct(productIdentifier: productIdentifier) + return Payment(product: testProduct, atomically: atomically, applicationUsername: "", callback: callback) + } } From 4eef88e0d97551aa2c2f387b6eb70051ade5edf8 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 20 Jan 2017 20:36:32 +0000 Subject: [PATCH 16/31] Integrate new PaymentQueueController into SwiftyStoreKit implementation --- SwiftyStoreKit.xcodeproj/project.pbxproj | 16 ++------ .../InAppCompleteTransactionsObserver.swift | 16 -------- SwiftyStoreKit/SwiftyStoreKit.swift | 37 ++++++------------- 3 files changed, 15 insertions(+), 54 deletions(-) diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 7e6bb01f..1070748f 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -10,14 +10,11 @@ 1592CD501E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; }; 1592CD511E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; }; 1592CD521E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; }; - 54B069911CF742CE00BAFE38 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; }; 54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; }; 54B069941CF742D600BAFE38 /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; }; - 54B069951CF742D900BAFE38 /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; }; 54B069961CF744DC00BAFE38 /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; }; 54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; }; - 6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; }; 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; }; 6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; }; 650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */; }; @@ -27,8 +24,6 @@ 650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; 650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; 650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; - 651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; - 651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; }; 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; @@ -63,12 +58,12 @@ C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */; }; C3099C091E2FCE3A00392A54 /* TestProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C081E2FCE3A00392A54 /* TestProduct.swift */; }; C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */; }; + C3099C191E3206C700392A54 /* CompleteTransactionsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */; }; C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; }; C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; }; C40C68101C29414C00B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; }; C40C68111C29419500B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; }; C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; }; - C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; }; C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; }; C4D74BC51C24CEDC0071AD3E /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; }; C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; }; @@ -174,6 +169,7 @@ C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentsControllerTests.swift; sourceTree = ""; }; C3099C081E2FCE3A00392A54 /* TestProduct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestProduct.swift; sourceTree = ""; }; C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestPaymentTransaction.swift; sourceTree = ""; }; + C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompleteTransactionsControllerTests.swift; sourceTree = ""; }; C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptRefreshRequest.swift; sourceTree = ""; }; C40C680F1C29414C00B60B7E /* OS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OS.swift; sourceTree = ""; }; C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = ""; }; @@ -283,6 +279,7 @@ 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */, C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */, 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */, + C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */, 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */, 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */, C3099C081E2FCE3A00392A54 /* TestProduct.swift */, @@ -586,9 +583,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 54B069911CF742CE00BAFE38 /* InAppCompleteTransactionsObserver.swift in Sources */, 1592CD521E27756500D321E6 /* AppleReceiptValidator.swift in Sources */, - 54B069951CF742D900BAFE38 /* InAppProductPurchaseRequest.swift in Sources */, 54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */, 54B069961CF744DC00BAFE38 /* OS.swift in Sources */, 54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */, @@ -619,8 +614,6 @@ files = ( C40C68101C29414C00B60B7E /* OS.swift in Sources */, 1592CD501E27756500D321E6 /* AppleReceiptValidator.swift in Sources */, - 651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */, - 6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */, 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */, C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */, 65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */, @@ -643,6 +636,7 @@ C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */, 65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */, 658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */, + C3099C191E3206C700392A54 /* CompleteTransactionsControllerTests.swift in Sources */, 658A084C1E2EC5960074A98F /* PaymentQueueSpy.swift in Sources */, C3099C091E2FCE3A00392A54 /* TestProduct.swift in Sources */, ); @@ -654,8 +648,6 @@ files = ( C40C68111C29419500B60B7E /* OS.swift in Sources */, 1592CD511E27756500D321E6 /* AppleReceiptValidator.swift in Sources */, - 651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */, - C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */, C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */, C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */, 65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */, diff --git a/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift b/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift index 6580703e..b3ed8d39 100644 --- a/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift +++ b/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift @@ -25,19 +25,6 @@ import StoreKit -extension SKPaymentTransactionState { - - var stringValue: String { - switch self { - case .purchasing: return "Purchasing" - case .purchased: return "Purchased" - case .failed: return "Failed" - case .restored: return "Restored" - case .deferred: return "Deferred" - } - } -} - class InAppCompleteTransactionsObserver: NSObject, SKPaymentTransactionObserver { private var callbackCalled: Bool = false @@ -69,9 +56,6 @@ class InAppCompleteTransactionsObserver: NSObject, SKPaymentTransactionObserver if callbackCalled { return } - if SwiftyStoreKit.hasInFlightPayments { - return - } var completedTransactions: [Product] = [] diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index 71a5ec9c..f0e22a0c 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -49,9 +49,6 @@ public class SwiftyStoreKit { private var inflightQueries: [Set: InAppProductQueryRequest] = [:] private var paymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()) - private var inflightPurchases: [String: InAppProductPurchaseRequest] = [:] - private var restoreRequest: InAppProductPurchaseRequest? - private var completeTransactionsObserver: InAppCompleteTransactionsObserver? private var receiptRefreshRequest: InAppReceiptRefreshRequest? private enum InternalErrorCode: Int { @@ -65,13 +62,11 @@ public class SwiftyStoreKit { public class var canMakePayments: Bool { return SKPaymentQueue.canMakePayments() } - - class var hasInFlightPayments: Bool { - return sharedInstance.inflightPurchases.count > 0 || sharedInstance.restoreRequest != nil - } + public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) { - sharedInstance.completeTransactionsObserver = InAppCompleteTransactionsObserver(atomically: atomically, callback: completion) + + sharedInstance.paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion)) } // MARK: Public methods @@ -114,19 +109,16 @@ public class SwiftyStoreKit { public class func restorePurchases(atomically: Bool = true, completion: @escaping (RestoreResults) -> ()) { - // TODO: paymentQueueController.restorePurchases - - sharedInstance.restoreRequest = InAppProductPurchaseRequest.restorePurchases(atomically: atomically) { results in + sharedInstance.paymentQueueController.restorePurchases(RestorePurchases(atomically: atomically) { results in - sharedInstance.restoreRequest = nil let results = sharedInstance.processRestoreResults(results) completion(results) - } + }) } public class func finishTransaction(_ transaction: PaymentTransaction) { - InAppProductPurchaseRequest.finishTransaction(transaction) + sharedInstance.paymentQueueController.finishTransaction(transaction) } /** @@ -212,20 +204,13 @@ public class SwiftyStoreKit { return } - // TODO: paymentQueueController.startPayment - - inflightPurchases[product.productIdentifier] = InAppProductPurchaseRequest.startPayment(product: product, atomically: atomically, applicationUsername: applicationUsername) { results in - - self.inflightPurchases[product.productIdentifier] = nil + paymentQueueController.startPayment(Payment(product: product, atomically: atomically, applicationUsername: applicationUsername) { result in - if let purchasedProductTransaction = results.first { - let returnValue = self.processPurchaseResult(purchasedProductTransaction) - completion(returnValue) - } - } + completion(self.processPurchaseResult(result)) + }) } - private func processPurchaseResult(_ result: InAppProductPurchaseRequest.TransactionResult) -> PurchaseResult { + private func processPurchaseResult(_ result: TransactionResult) -> PurchaseResult { switch result { case .purchased(let product): return .success(product: product) @@ -236,7 +221,7 @@ public class SwiftyStoreKit { } } - private func processRestoreResults(_ results: [InAppProductPurchaseRequest.TransactionResult]) -> RestoreResults { + private func processRestoreResults(_ results: [TransactionResult]) -> RestoreResults { var restoredProducts: [Product] = [] var restoreFailedProducts: [(Swift.Error, String?)] = [] for result in results { From bc50b3a231dd7c95ab7b9c5a5dfeda2245318645 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Fri, 20 Jan 2017 20:59:47 +0000 Subject: [PATCH 17/31] Allow PaymentController to enqueue multiple payments with same product identifier. Added tests to check correct behaviour. --- SwiftyStoreKit/PaymentQueueController.swift | 7 +- SwiftyStoreKit/PaymentsController.swift | 20 ++-- .../PaymentsControllerTests.swift | 106 ++++++++++++++++-- 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index cb8d2796..03fd2490 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -85,16 +85,11 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { public func startPayment(_ payment: Payment) { - if paymentsController.hasPayment(payment) { - // return .inProgress - return - } - let skPayment = SKMutablePayment(product: payment.product) skPayment.applicationUsername = payment.applicationUsername paymentQueue.add(skPayment) - paymentsController.insert(payment) + paymentsController.append(payment) } public func restorePurchases(_ restorePurchases: RestorePurchases) { diff --git a/SwiftyStoreKit/PaymentsController.swift b/SwiftyStoreKit/PaymentsController.swift index 611589d7..337593f2 100644 --- a/SwiftyStoreKit/PaymentsController.swift +++ b/SwiftyStoreKit/PaymentsController.swift @@ -42,35 +42,37 @@ public struct Payment: Hashable { public class PaymentsController: TransactionController { - private var payments: Set = [] + private var payments: [Payment] = [] public init() { } - private func findPayment(withProductIdentifier identifier: String) -> Payment? { + private func findPaymentIndex(withProductIdentifier identifier: String) -> Int? { for payment in payments { if payment.product.productIdentifier == identifier { - return payment + return payments.index(of: payment) } } return nil } public func hasPayment(_ payment: Payment) -> Bool { - return findPayment(withProductIdentifier: payment.product.productIdentifier) != nil + return findPaymentIndex(withProductIdentifier: payment.product.productIdentifier) != nil } - public func insert(_ payment: Payment) { - payments.insert(payment) + public func append(_ payment: Payment) { + payments.append(payment) } public func processTransaction(_ transaction: SKPaymentTransaction, on paymentQueue: PaymentQueue) -> Bool { let transactionProductIdentifier = transaction.payment.productIdentifier - guard let payment = findPayment(withProductIdentifier: transactionProductIdentifier) else { + guard let paymentIndex = findPaymentIndex(withProductIdentifier: transactionProductIdentifier) else { return false } + let payment = payments[paymentIndex] + let transactionState = transaction.transactionState if transactionState == .purchased { @@ -82,7 +84,7 @@ public class PaymentsController: TransactionController { if payment.atomically { paymentQueue.finishTransaction(transaction) } - payments.remove(payment) + payments.remove(at: paymentIndex) return true } if transactionState == .failed { @@ -92,7 +94,7 @@ public class PaymentsController: TransactionController { payment.callback(.failed(error: transaction.error ?? altError)) paymentQueue.finishTransaction(transaction) - payments.remove(payment) + payments.remove(at: paymentIndex) return true } diff --git a/SwiftyStoreKitTests/PaymentsControllerTests.swift b/SwiftyStoreKitTests/PaymentsControllerTests.swift index 841d2257..27a3311e 100644 --- a/SwiftyStoreKitTests/PaymentsControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentsControllerTests.swift @@ -32,12 +32,12 @@ class PaymentsControllerTests: XCTestCase { let payment = makeTestPayment(productIdentifier: "com.SwiftyStoreKit.product1") { result in } - let paymentsController = makePaymentsController(insertPayment: payment) + let paymentsController = makePaymentsController(appendPayments: [payment]) XCTAssertTrue(paymentsController.hasPayment(payment)) } - func testProcessTransaction_when_transactionStatePurchased_then_removesPayment_finishesTransaction_callsCallback() { + func testProcessTransaction_when_onePayment_transactionStatePurchased_then_removesPayment_finishesTransaction_callsCallback() { let productIdentifier = "com.SwiftyStoreKit.product1" let testProduct = TestProduct(productIdentifier: productIdentifier) @@ -54,7 +54,7 @@ class PaymentsControllerTests: XCTestCase { } } - let paymentsController = makePaymentsController(insertPayment: payment) + let paymentsController = makePaymentsController(appendPayments: [payment]) let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .purchased) @@ -71,7 +71,7 @@ class PaymentsControllerTests: XCTestCase { XCTAssertEqual(spy.finishTransactionCalledCount, 1) } - func testProcessTransaction_when_transactionStateFailed_then_removesPayment_finishesTransaction_callsCallback() { + func testProcessTransaction_when_onePayment_transactionStateFailed_then_removesPayment_finishesTransaction_callsCallback() { let productIdentifier = "com.SwiftyStoreKit.product1" let testProduct = TestProduct(productIdentifier: productIdentifier) @@ -88,7 +88,7 @@ class PaymentsControllerTests: XCTestCase { } } - let paymentsController = makePaymentsController(insertPayment: payment) + let paymentsController = makePaymentsController(appendPayments: [payment]) let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .failed) @@ -105,11 +105,103 @@ class PaymentsControllerTests: XCTestCase { XCTAssertEqual(spy.finishTransactionCalledCount, 1) } - func makePaymentsController(insertPayment payment: Payment) -> PaymentsController { + func testProcessTransaction_when_twoPaymentsSameId_firstTransactionStatePurchased_secondTransactionStateFailed_then_removesPayments_finishesTransactions_callsCallbacks() { + + let productIdentifier = "com.SwiftyStoreKit.product1" + let testProduct1 = TestProduct(productIdentifier: productIdentifier) + + var callback1Called = false + let payment1 = makeTestPayment(product: testProduct1) { result in + + callback1Called = true + if case .purchased(let product) = result { + XCTAssertEqual(product.productId, productIdentifier) + } + else { + XCTFail("expected purchased callback with product id") + } + } + + let testProduct2 = TestProduct(productIdentifier: productIdentifier) + + var callback2Called = false + let payment2 = makeTestPayment(product: testProduct2) { result in + callback2Called = true + if case .failed(_) = result { + + } + else { + XCTFail("expected failed callback with error") + } + } + + let paymentsController = makePaymentsController(appendPayments: [payment1, payment2]) + + let transaction1 = TestPaymentTransaction(payment: SKPayment(product: testProduct1), transactionState: .purchased) + let transaction2 = TestPaymentTransaction(payment: SKPayment(product: testProduct2), transactionState: .failed) + + let spy = PaymentQueueSpy() + + let remainingTransactions = paymentsController.processTransactions([transaction1, transaction2], on: spy) + + XCTAssertEqual(remainingTransactions.count, 0) + + XCTAssertFalse(paymentsController.hasPayment(payment1)) + XCTAssertFalse(paymentsController.hasPayment(payment2)) + + XCTAssertTrue(callback1Called) + XCTAssertTrue(callback2Called) + + XCTAssertEqual(spy.finishTransactionCalledCount, 2) + } + + func testProcessTransaction_when_twoPaymentsSameId_firstPayment_transactionStatePurchased_then_removesFirstPayment_finishesTransaction_callsCallback() { + + let productIdentifier = "com.SwiftyStoreKit.product1" + let testProduct1 = TestProduct(productIdentifier: productIdentifier) + + var callback1Called = false + let payment1 = makeTestPayment(product: testProduct1) { result in + + callback1Called = true + if case .purchased(let product) = result { + XCTAssertEqual(product.productId, productIdentifier) + } + else { + XCTFail("expected purchased callback with product id") + } + } + + let testProduct2 = TestProduct(productIdentifier: productIdentifier) + let payment2 = makeTestPayment(product: testProduct2) { result in + + XCTFail("unexpected callback for second payment") + } + + let paymentsController = makePaymentsController(appendPayments: [payment1, payment2]) + + let transaction1 = TestPaymentTransaction(payment: SKPayment(product: testProduct1), transactionState: .purchased) + + let spy = PaymentQueueSpy() + + let remainingTransactions = paymentsController.processTransactions([transaction1], on: spy) + + XCTAssertEqual(remainingTransactions.count, 0) + + // First one removed, but second one with same identifier still there + XCTAssertTrue(paymentsController.hasPayment(payment2)) + + XCTAssertTrue(callback1Called) + + XCTAssertEqual(spy.finishTransactionCalledCount, 1) + } + + + func makePaymentsController(appendPayments payments: [Payment]) -> PaymentsController { let paymentsController = PaymentsController() - paymentsController.insert(payment) + payments.forEach { paymentsController.append($0) } return paymentsController } From 226ded58208a3fc80192da3c955727bf5c6dac24 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 02:29:27 +0000 Subject: [PATCH 18/31] Documented SKPaymentQueue behaviour and implementation in SwiftyStoreKit --- SwiftyStoreKit/PaymentQueueController.swift | 31 +++++++++++++-------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 03fd2490..22a257f9 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -122,18 +122,25 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { /* - - The payment queue seems to process payments in-order, however any calls to restorePurchases can easily jump - ahead of the queue as the user flows for restorePurchases are simpler. - - SKPaymentQueue rejects multiple restorePurchases calls - - Having one payment queue observer for each request causes extra processing - - Can a failed translation ever belong to a restore purchases request? - No. restoreCompletedTransactionsFailedWithError is called instead. - - */ + * Some notes about how requests are processed by SKPaymentQueue: + * + * SKPaymentQueue is used to queue payments or restore purchases requests. + * Payments are processed serially and in-order and require user interaction. + * Restore purchases requests don't require user interaction and can jump ahead of the queue. + * SKPaymentQueue rejects multiple restore purchases calls. + * Having one payment queue observer for each request causes extra processing + * Failed translations only ever belong to queued payment request. + * restoreCompletedTransactionsFailedWithError is called when a restore payments request fails. + * A complete transactions handler is require to catch any transactions that are updated when the app is not running. + * Registering a complete transactions handler when the app launches ensures that any pending transactions can be cleared. + * If a complete transactions handler is missing, pending transactions can be mis-attributed to any new incoming payments or restore purchases. + * + * The order in which transaction updates are processed is: + * 1. payments (transactionState: .purchased and .failed for matching product identifiers) + * 2. restore purchases (transactionState: .restored, or restoreCompletedTransactionsFailedWithError, or paymentQueueRestoreCompletedTransactionsFinished) + * 3. complete transactions (transactionState: .purchased, .failed, .restored, .deferred) + * Any transactions where state == .purchasing are ignored. + */ var unhandledTransactions = paymentsController.processTransactions(transactions, on: paymentQueue) unhandledTransactions = restorePurchasesController.processTransactions(unhandledTransactions, on: paymentQueue) From 61a1cb85ba304a3093c967ebd9e4d7203b260896 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 02:30:52 +0000 Subject: [PATCH 19/31] Remove old InAppCompleteTransactionsObserver and InAppPurchaseRequest --- SwiftyStoreKit.xcodeproj/project.pbxproj | 16 +- .../InAppCompleteTransactionsObserver.swift | 83 -------- .../InAppProductPurchaseRequest.swift | 184 ------------------ 3 files changed, 6 insertions(+), 277 deletions(-) delete mode 100644 SwiftyStoreKit/InAppCompleteTransactionsObserver.swift delete mode 100644 SwiftyStoreKit/InAppProductPurchaseRequest.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 1070748f..4a4a3b61 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -131,14 +131,12 @@ 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleReceiptValidator.swift; sourceTree = ""; }; 54C0D52C1CF7404500F90BCE /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6502F5FE1B985833004E342D /* SwiftyStoreKit_iOSDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftyStoreKit_iOSDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppProductPurchaseRequest.swift; sourceTree = ""; }; 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppProductQueryRequest.swift; sourceTree = ""; }; 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyStoreKit.swift; sourceTree = ""; }; 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesControllerTests.swift; sourceTree = ""; }; 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesController.swift; sourceTree = ""; }; 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompleteTransactionsController.swift; sourceTree = ""; }; - 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppCompleteTransactionsObserver.swift; sourceTree = ""; }; 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = ""; }; 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = ""; }; 658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftyStoreKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -253,21 +251,19 @@ 6502F6001B985833004E342D /* SwiftyStoreKit */ = { isa = PBXGroup; children = ( - 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */, + 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */, + 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */, 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */, + 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, + 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */, + 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */, + 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */, C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */, C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */, 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */, - 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */, 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */, - 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */, - 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */, C40C680F1C29414C00B60B7E /* OS.swift */, 65F7DF931DCD536100835D30 /* Platforms */, - 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, - 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */, - 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */, - 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */, ); path = SwiftyStoreKit; sourceTree = ""; diff --git a/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift b/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift deleted file mode 100644 index b3ed8d39..00000000 --- a/SwiftyStoreKit/InAppCompleteTransactionsObserver.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// InAppCompleteTransactionsObserver.swift -// SwiftyStoreKit -// -// Copyright (c) 2016 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 StoreKit - -class InAppCompleteTransactionsObserver: NSObject, SKPaymentTransactionObserver { - - private var callbackCalled: Bool = false - - typealias TransactionsCallback = ([Product]) -> () - - var paymentQueue: SKPaymentQueue { - return SKPaymentQueue.default() - } - - let atomically: Bool - - deinit { - paymentQueue.remove(self) - } - - let callback: TransactionsCallback - - init(atomically: Bool, callback: @escaping TransactionsCallback) { - - self.atomically = atomically - self.callback = callback - super.init() - paymentQueue.add(self) - } - - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - - if callbackCalled { - return - } - - var completedTransactions: [Product] = [] - - for transaction in transactions { - - let transactionState = transaction.transactionState - - if transactionState != .purchasing { - - let product = Product(productId: transaction.payment.productIdentifier, transaction: transaction, needsFinishTransaction: !atomically) - - completedTransactions.append(product) - - print("Finishing transaction for payment \"\(transaction.payment.productIdentifier)\" with state: \(transactionState.stringValue)") - - if atomically { - paymentQueue.finishTransaction(transaction) - } - } - } - callbackCalled = true - - callback(completedTransactions) - } -} diff --git a/SwiftyStoreKit/InAppProductPurchaseRequest.swift b/SwiftyStoreKit/InAppProductPurchaseRequest.swift deleted file mode 100644 index 73180a38..00000000 --- a/SwiftyStoreKit/InAppProductPurchaseRequest.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// InAppProductPurchaseRequest.swift -// SwiftyStoreKit -// -// Copyright (c) 2015 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 StoreKit -import Foundation - -class InAppProductPurchaseRequest: NSObject, SKPaymentTransactionObserver { - - enum TransactionResult { - case purchased(product: Product) - case restored(product: Product) - case failed(error: Error) - } - - typealias RequestCallback = ([TransactionResult]) -> () - private let callback: RequestCallback - private var purchases : [SKPaymentTransactionState: [String]] = [:] - - var paymentQueue: SKPaymentQueue { - return SKPaymentQueue.default() - } - - let product : SKProduct? - let atomically: Bool - - deinit { - paymentQueue.remove(self) - } - // Initialiser for product purchase - private init(product: SKProduct?, atomically: Bool, callback: @escaping RequestCallback) { - - self.atomically = atomically - self.product = product - self.callback = callback - super.init() - paymentQueue.add(self) - } - // MARK: Public methods - class func startPayment(product: SKProduct, atomically: Bool, applicationUsername: String = "", callback: @escaping RequestCallback) -> InAppProductPurchaseRequest { - let request = InAppProductPurchaseRequest(product: product, atomically: atomically, callback: callback) - request.startPayment(product, applicationUsername: applicationUsername) - return request - } - class func restorePurchases(atomically: Bool, callback: @escaping RequestCallback) -> InAppProductPurchaseRequest { - let request = InAppProductPurchaseRequest(product: nil, atomically: atomically, callback: callback) - request.startRestorePurchases() - return request - } - - class func finishTransaction(_ transaction: PaymentTransaction) { - guard let skTransaction = transaction as? SKPaymentTransaction else { - print("Object is not a SKPaymentTransaction: \(transaction)") - return - } - SKPaymentQueue.default().finishTransaction(skTransaction) - } - - // MARK: Private methods - private func startPayment(_ product: SKProduct, applicationUsername: String = "") { - let payment = SKMutablePayment(product: product) - payment.applicationUsername = applicationUsername - - DispatchQueue.global(qos: .default).async { - self.paymentQueue.add(payment) - } - } - private func startRestorePurchases() { - - DispatchQueue.global(qos: .default).async { - self.paymentQueue.restoreCompletedTransactions() - } - } - - // MARK: SKPaymentTransactionObserver - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - - var transactionResults: [TransactionResult] = [] - - for transaction in transactions { - - let transactionProductIdentifier = transaction.payment.productIdentifier - - var isPurchaseRequest = false - if let productIdentifier = product?.productIdentifier { - if transactionProductIdentifier != productIdentifier { - continue - } - isPurchaseRequest = true - } - - let transactionState = transaction.transactionState - - switch transactionState { - case .purchased: - if isPurchaseRequest { - let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically) - transactionResults.append(.purchased(product: product)) - if atomically { - paymentQueue.finishTransaction(transaction) - } - } - case .failed: - // TODO: How to discriminate between purchase and restore? - // It appears that in some edge cases transaction.error is nil here. Since returning an associated error is - // mandatory, return a default one if needed - let message = "Transaction failed for product ID: \(transactionProductIdentifier)" - let altError = NSError(domain: SKErrorDomain, code: 0, userInfo: [ NSLocalizedDescriptionKey: message ]) - transactionResults.append(.failed(error: transaction.error ?? altError)) - paymentQueue.finishTransaction(transaction) - case .restored: - if !isPurchaseRequest { - let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically) - transactionResults.append(.restored(product: product)) - if atomically { - paymentQueue.finishTransaction(transaction) - } - } - case .purchasing: - // In progress: do nothing - break - case .deferred: - break - } - // Keep track of payments - if let _ = purchases[transactionState] { - purchases[transactionState]?.append(transactionProductIdentifier) - } - else { - purchases[transactionState] = [ transactionProductIdentifier ] - } - } - if transactionResults.count > 0 { - DispatchQueue.main.async { - self.callback(transactionResults) - } - } - } - - func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { - - } - - func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { - - DispatchQueue.main.async { - self.callback([.failed(error: error)]) - } - } - - func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { - // This method will be called after all purchases have been restored (includes the case of no purchases) - guard let restored = purchases[.restored], restored.count > 0 else { - - self.callback([]) - return - } - } - - func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) { - - } -} - From 65d135f182d4750e0c134b5d1bd7514718a67f12 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 02:38:03 +0000 Subject: [PATCH 20/31] Add applicationUsername support to restore purchases --- SwiftyStoreKit/PaymentQueueController.swift | 4 ++-- SwiftyStoreKit/RestorePurchasesController.swift | 4 +++- SwiftyStoreKit/SwiftyStoreKit.swift | 4 ++-- SwiftyStoreKitTests/PaymentQueueSpy.swift | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 22a257f9..56aa6d55 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -49,7 +49,7 @@ public protocol PaymentQueue: class { func add(_ payment: SKPayment) - func restoreCompletedTransactions() + func restoreCompletedTransactions(withApplicationUsername username: String?) func finishTransaction(_ transaction: SKPaymentTransaction) } @@ -99,7 +99,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { return } - paymentQueue.restoreCompletedTransactions() + paymentQueue.restoreCompletedTransactions(withApplicationUsername: restorePurchases.applicationUsername) restorePurchasesController.restorePurchases = restorePurchases } diff --git a/SwiftyStoreKit/RestorePurchasesController.swift b/SwiftyStoreKit/RestorePurchasesController.swift index 0a4d3054..0327d69b 100644 --- a/SwiftyStoreKit/RestorePurchasesController.swift +++ b/SwiftyStoreKit/RestorePurchasesController.swift @@ -27,10 +27,12 @@ import StoreKit public struct RestorePurchases { public let atomically: Bool + public let applicationUsername: String? public let callback: ([TransactionResult]) -> () - public init(atomically: Bool, callback: @escaping ([TransactionResult]) -> ()) { + public init(atomically: Bool, applicationUsername: String? = nil, callback: @escaping ([TransactionResult]) -> ()) { self.atomically = atomically + self.applicationUsername = applicationUsername self.callback = callback } } diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index f0e22a0c..ec85980a 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -107,9 +107,9 @@ public class SwiftyStoreKit { } } - public class func restorePurchases(atomically: Bool = true, completion: @escaping (RestoreResults) -> ()) { + public class func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> ()) { - sharedInstance.paymentQueueController.restorePurchases(RestorePurchases(atomically: atomically) { results in + sharedInstance.paymentQueueController.restorePurchases(RestorePurchases(atomically: atomically, applicationUsername: applicationUsername) { results in let results = sharedInstance.processRestoreResults(results) completion(results) diff --git a/SwiftyStoreKitTests/PaymentQueueSpy.swift b/SwiftyStoreKitTests/PaymentQueueSpy.swift index 8d31b2df..092ee169 100644 --- a/SwiftyStoreKitTests/PaymentQueueSpy.swift +++ b/SwiftyStoreKitTests/PaymentQueueSpy.swift @@ -35,7 +35,7 @@ class PaymentQueueSpy: PaymentQueue { payments.append(payment) } - func restoreCompletedTransactions() { + func restoreCompletedTransactions(withApplicationUsername username: String?) { restoreCompletedTransactionCalledCount += 1 } From 1170440c36701b3243ed44a49e4618a1a48d3d94 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 02:51:30 +0000 Subject: [PATCH 21/31] Update RestorePurchasesController to accumulate all relevant updated transaction in an array, and call the callback in restoreCompletedTransactionsFailed or restoreCompletedTransactionsFinished --- .../RestorePurchasesController.swift | 19 +++++++++---------- .../PaymentQueueControllerTests.swift | 3 ++- .../RestorePurchasesControllerTests.swift | 4 +++- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/SwiftyStoreKit/RestorePurchasesController.swift b/SwiftyStoreKit/RestorePurchasesController.swift index 0327d69b..4aa24f31 100644 --- a/SwiftyStoreKit/RestorePurchasesController.swift +++ b/SwiftyStoreKit/RestorePurchasesController.swift @@ -42,6 +42,8 @@ public class RestorePurchasesController: TransactionController { public var restorePurchases: RestorePurchases? + private var restoredProducts: [TransactionResult] = [] + public init() { } public func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? { @@ -68,7 +70,6 @@ public class RestorePurchasesController: TransactionController { } var unhandledTransactions: [SKPaymentTransaction] = [] - var restoredProducts: [TransactionResult] = [] for transaction in transactions { if let restoredProduct = processTransaction(transaction, atomically: restorePurchases.atomically, on: paymentQueue) { restoredProducts.append(.restored(product: restoredProduct)) @@ -77,11 +78,6 @@ public class RestorePurchasesController: TransactionController { unhandledTransactions.append(transaction) } } - if restoredProducts.count > 0 { - restorePurchases.callback(restoredProducts) - } - // Reset to nil after purchases complete - self.restorePurchases = nil return unhandledTransactions } @@ -91,9 +87,11 @@ public class RestorePurchasesController: TransactionController { guard let restorePurchases = restorePurchases else { return } - restorePurchases.callback([.failed(error: error)]) + restoredProducts.append(.failed(error: error)) + restorePurchases.callback(restoredProducts) - // Reset to nil after error received + // Reset state after error received + restoredProducts = [] self.restorePurchases = nil } @@ -103,9 +101,10 @@ public class RestorePurchasesController: TransactionController { guard let restorePurchases = restorePurchases else { return } - restorePurchases.callback([]) + restorePurchases.callback(restoredProducts) - // Reset to nil after error transactions finished + // Reset state after error transactions finished + restoredProducts = [] self.restorePurchases = nil } } diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift index 505ecf33..b626fce0 100644 --- a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -130,10 +130,11 @@ class PaymentQueueControllerTests: XCTestCase { paymentQueueController.startPayment(testPayment) paymentQueueController.restorePurchases(restorePurchases) - + paymentQueueController.completeTransactions(completeTransactions) paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions) + paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue()) // verify XCTAssertTrue(paymentCallbackCalled) diff --git a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift index 5d436775..9dd518c1 100644 --- a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift +++ b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift @@ -54,6 +54,7 @@ class RestorePurchasesControllerTests: XCTestCase { let spy = PaymentQueueSpy() let remainingTransactions = restorePurchasesController.processTransactions([transaction], on: spy) + restorePurchasesController.restoreCompletedTransactionsFinished() XCTAssertEqual(remainingTransactions.count, 0) @@ -107,7 +108,8 @@ class RestorePurchasesControllerTests: XCTestCase { let spy = PaymentQueueSpy() let remainingTransactions = restorePurchasesController.processTransactions(transactions, on: spy) - + restorePurchasesController.restoreCompletedTransactionsFinished() + XCTAssertEqual(remainingTransactions.count, 2) XCTAssertTrue(callbackCalled) From e9d8f2dc81b33f1fb712df9b3879d88373c154de Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 04:02:58 +0000 Subject: [PATCH 22/31] Additional PaymentQueueController tests --- SwiftyStoreKit/PaymentQueueController.swift | 3 +- .../PaymentQueueControllerTests.swift | 112 +++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index 56aa6d55..d99bdb87 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -130,7 +130,8 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { * SKPaymentQueue rejects multiple restore purchases calls. * Having one payment queue observer for each request causes extra processing * Failed translations only ever belong to queued payment request. - * restoreCompletedTransactionsFailedWithError is called when a restore payments request fails. + * restoreCompletedTransactionsFailedWithError is always called when a restore purchases request fails. + * paymentQueueRestoreCompletedTransactionsFinished is always called following 0 or more update transactions when a restore purchases request succeeds. * A complete transactions handler is require to catch any transactions that are updated when the app is not running. * Registering a complete transactions handler when the app launches ensures that any pending transactions can be cleared. * If a complete transactions handler is missing, pending transactions can be mis-attributed to any new incoming payments or restore purchases. diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift index b626fce0..8dd91bb4 100644 --- a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -72,7 +72,7 @@ class PaymentQueueControllerTests: XCTestCase { } // MARK: SKPaymentTransactionObserver callbacks - func testPaymentQueue_when_oneTransactionForEachState_then_correctCallbacksCalled() { + func testPaymentQueue_when_oneTransactionForEachState_onePayment_oneRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() { // setup let spy = PaymentQueueSpy() @@ -142,6 +142,116 @@ class PaymentQueueControllerTests: XCTestCase { XCTAssertTrue(completeTransactionsCallbackCalled) } + func testPaymentQueue_when_oneTransactionForEachState_onePayment_noRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() { + + // setup + let spy = PaymentQueueSpy() + + let paymentQueueController = PaymentQueueController(paymentQueue: spy) + + let purchasedProductIdentifier = "com.SwiftyStoreKit.product1" + let failedProductIdentifier = "com.SwiftyStoreKit.product2" + let restoredProductIdentifier = "com.SwiftyStoreKit.product3" + let deferredProductIdentifier = "com.SwiftyStoreKit.product4" + let purchasingProductIdentifier = "com.SwiftyStoreKit.product5" + + let transactions = [ + makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased), + makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed), + makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored), + makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred), + makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing), + ] + + + var paymentCallbackCalled = false + let testPayment = makeTestPayment(productIdentifier: purchasedProductIdentifier) { result in + paymentCallbackCalled = true + if case .purchased(let product) = result { + XCTAssertEqual(product.productId, purchasedProductIdentifier) + } + else { + XCTFail("expected purchased callback with product id") + } + } + + var completeTransactionsCallbackCalled = false + let completeTransactions = CompleteTransactions(atomically: true) { products in + completeTransactionsCallbackCalled = true + XCTAssertEqual(products.count, 3) + XCTAssertEqual(products[0].productId, failedProductIdentifier) + XCTAssertEqual(products[1].productId, restoredProductIdentifier) + XCTAssertEqual(products[2].productId, deferredProductIdentifier) + } + + // run + paymentQueueController.startPayment(testPayment) + + paymentQueueController.completeTransactions(completeTransactions) + + paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions) + paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue()) + + // verify + XCTAssertTrue(paymentCallbackCalled) + XCTAssertTrue(completeTransactionsCallbackCalled) + } + + func testPaymentQueue_when_oneTransactionForEachState_noPayments_oneRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() { + + // setup + let spy = PaymentQueueSpy() + + let paymentQueueController = PaymentQueueController(paymentQueue: spy) + + let purchasedProductIdentifier = "com.SwiftyStoreKit.product1" + let failedProductIdentifier = "com.SwiftyStoreKit.product2" + let restoredProductIdentifier = "com.SwiftyStoreKit.product3" + let deferredProductIdentifier = "com.SwiftyStoreKit.product4" + let purchasingProductIdentifier = "com.SwiftyStoreKit.product5" + + let transactions = [ + makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased), + makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed), + makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored), + makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred), + makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing), + ] + + var restorePurchasesCallbackCalled = false + let restorePurchases = RestorePurchases(atomically: true) { results in + restorePurchasesCallbackCalled = true + XCTAssertEqual(results.count, 1) + let first = results.first! + if case .restored(let restoredProduct) = first { + XCTAssertEqual(restoredProduct.productId, restoredProductIdentifier) + } + else { + XCTFail("expected restored callback with product") + } + } + + var completeTransactionsCallbackCalled = false + let completeTransactions = CompleteTransactions(atomically: true) { products in + completeTransactionsCallbackCalled = true + XCTAssertEqual(products.count, 3) + XCTAssertEqual(products[0].productId, purchasedProductIdentifier) + XCTAssertEqual(products[1].productId, failedProductIdentifier) + XCTAssertEqual(products[2].productId, deferredProductIdentifier) + } + + // run + paymentQueueController.restorePurchases(restorePurchases) + + paymentQueueController.completeTransactions(completeTransactions) + + paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions) + paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue()) + + // verify + XCTAssertTrue(restorePurchasesCallbackCalled) + XCTAssertTrue(completeTransactionsCallbackCalled) + } // MARK: Helpers func makeTestPaymentTransaction(productIdentifier: String, transactionState: SKPaymentTransactionState) -> TestPaymentTransaction { From 439dc61ed5bf242c1de4f9026a840f1b79e57ec6 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 04:15:30 +0000 Subject: [PATCH 23/31] Moved logic to retrieve products info to ProductsInfoController --- SwiftyStoreKit.xcodeproj/project.pbxproj | 8 +++ SwiftyStoreKit/ProductsInfoController.swift | 77 +++++++++++++++++++++ SwiftyStoreKit/SwiftyStoreKit.swift | 60 ++++------------ 3 files changed, 98 insertions(+), 47 deletions(-) create mode 100644 SwiftyStoreKit/ProductsInfoController.swift diff --git a/SwiftyStoreKit.xcodeproj/project.pbxproj b/SwiftyStoreKit.xcodeproj/project.pbxproj index 4a4a3b61..6c58c684 100644 --- a/SwiftyStoreKit.xcodeproj/project.pbxproj +++ b/SwiftyStoreKit.xcodeproj/project.pbxproj @@ -24,6 +24,9 @@ 650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; 650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; 650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; }; + 650307FC1E33154F001332A4 /* ProductsInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307FB1E33154F001332A4 /* ProductsInfoController.swift */; }; + 650307FD1E33154F001332A4 /* ProductsInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307FB1E33154F001332A4 /* ProductsInfoController.swift */; }; + 650307FE1E33154F001332A4 /* ProductsInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307FB1E33154F001332A4 /* ProductsInfoController.swift */; }; 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; }; @@ -137,6 +140,7 @@ 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesControllerTests.swift; sourceTree = ""; }; 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesController.swift; sourceTree = ""; }; 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompleteTransactionsController.swift; sourceTree = ""; }; + 650307FB1E33154F001332A4 /* ProductsInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductsInfoController.swift; sourceTree = ""; }; 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = ""; }; 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = ""; }; 658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftyStoreKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -253,6 +257,7 @@ children = ( 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */, 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */, + 650307FB1E33154F001332A4 /* ProductsInfoController.swift */, 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */, 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */, 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */, @@ -584,6 +589,7 @@ 54B069961CF744DC00BAFE38 /* OS.swift in Sources */, 54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */, 65F70ACB1E2EDC3700BF040D /* PaymentsController.swift in Sources */, + 650307FE1E33154F001332A4 /* ProductsInfoController.swift in Sources */, 650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, @@ -613,6 +619,7 @@ 6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */, C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */, 65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */, + 650307FC1E33154F001332A4 /* ProductsInfoController.swift in Sources */, 650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, @@ -647,6 +654,7 @@ C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */, C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */, 65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */, + 650307FD1E33154F001332A4 /* ProductsInfoController.swift in Sources */, 650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */, 658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */, 653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */, diff --git a/SwiftyStoreKit/ProductsInfoController.swift b/SwiftyStoreKit/ProductsInfoController.swift new file mode 100644 index 00000000..4095fe11 --- /dev/null +++ b/SwiftyStoreKit/ProductsInfoController.swift @@ -0,0 +1,77 @@ +// +// ProductsInfoController.swift +// SwiftyStoreKit +// +// Copyright (c) 2015 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 +import StoreKit + +class ProductsInfoController: NSObject { + + // MARK: Private declarations + + // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id + private var inflightQueries: [Set: InAppProductQueryRequest] = [:] + + private(set) var products: [String: SKProduct] = [:] + + private func addProduct(_ product: SKProduct) { + products[product.productIdentifier] = product + } + + private func allProductsMatching(_ productIds: Set) -> Set? { + + var requestedProducts = Set() + + for productId in productIds { + + guard let product = products[productId] else { + return nil + } + requestedProducts.insert(product) + } + return requestedProducts + } + + private func requestProducts(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { + + inflightQueries[productIds] = InAppProductQueryRequest.startQuery(productIds) { result in + + self.inflightQueries[productIds] = nil + for product in result.retrievedProducts { + self.addProduct(product) + } + completion(result) + } + } + + func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { + + guard let products = allProductsMatching(productIds) else { + + requestProducts(productIds, completion: completion) + return + } + completion(RetrieveResults(retrievedProducts: products, invalidProductIDs: [], error: nil)) + } + +} diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index ec85980a..abbb8cc4 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -26,28 +26,9 @@ import StoreKit public class SwiftyStoreKit { - // MARK: Private declarations - private class InAppPurchaseStore { - var products: [String: SKProduct] = [:] - func addProduct(_ product: SKProduct) { - products[product.productIdentifier] = product - } - func allProductsMatching(_ productIds: Set) -> Set? { - var requestedProducts = Set() - for productId in productIds { - guard let product = products[productId] else { - return nil - } - requestedProducts.insert(product) - } - return requestedProducts - } - } - private var store: InAppPurchaseStore = InAppPurchaseStore() + private let productsInfoController = ProductsInfoController() - // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id - private var inflightQueries: [Set: InAppProductQueryRequest] = [:] - private var paymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()) + private let paymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()) private var receiptRefreshRequest: InAppReceiptRefreshRequest? @@ -63,21 +44,10 @@ public class SwiftyStoreKit { return SKPaymentQueue.canMakePayments() } - - public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) { - - sharedInstance.paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion)) - } - - // MARK: Public methods + // MARK: Public methods - Purchases public class func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { - guard let products = sharedInstance.store.allProductsMatching(productIds) else { - - sharedInstance.requestProducts(productIds, completion: completion) - return - } - completion(RetrieveResults(retrievedProducts: products, invalidProductIDs: [], error: nil)) + return sharedInstance.productsInfoController.retrieveProductsInfo(productIds, completion: completion) } /** @@ -89,7 +59,7 @@ public class SwiftyStoreKit { */ public class func purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) { - if let product = sharedInstance.store.products[productId] { + if let product = sharedInstance.productsInfoController.products[productId] { sharedInstance.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion) } else { @@ -116,11 +86,19 @@ public class SwiftyStoreKit { }) } + public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) { + + sharedInstance.paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion)) + } + + public class func finishTransaction(_ transaction: PaymentTransaction) { sharedInstance.paymentQueueController.finishTransaction(transaction) } + // MARK: Public methods - Receipt verification + /** * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL */ @@ -237,18 +215,6 @@ public class SwiftyStoreKit { return RestoreResults(restoredProducts: restoredProducts, restoreFailedProducts: restoreFailedProducts) } - private func requestProducts(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { - - inflightQueries[productIds] = InAppProductQueryRequest.startQuery(productIds) { result in - - self.inflightQueries[productIds] = nil - for product in result.retrievedProducts { - self.store.addProduct(product) - } - completion(result) - } - } - private func storeInternalError(code: Int = 0, description: String = "") -> NSError { return NSError(domain: "SwiftyStoreKit", code: code, userInfo: [ NSLocalizedDescriptionKey: description ]) } From 03d563a1402bdfde945a8f5eeb6bdb8280847ae2 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 11:40:03 +0000 Subject: [PATCH 24/31] Clearer separation between SwiftyStoreKit class and instance methods / variables. Added internal initialiser and methods to enable testability --- SwiftyStoreKit/SwiftyStoreKit.swift | 237 +++++++++++++++++----------- 1 file changed, 141 insertions(+), 96 deletions(-) diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index abbb8cc4..3ec5dfbd 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -26,9 +26,9 @@ import StoreKit public class SwiftyStoreKit { - private let productsInfoController = ProductsInfoController() + private let productsInfoController: ProductsInfoController - private let paymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()) + private let paymentQueueController: PaymentQueueController private var receiptRefreshRequest: InAppReceiptRefreshRequest? @@ -36,36 +36,29 @@ public class SwiftyStoreKit { case restoredPurchaseWhenPurchasing = 0 case purchasedWhenRestoringPurchase = 1 } - - // MARK: Singleton - private static let sharedInstance = SwiftyStoreKit() - public class var canMakePayments: Bool { - return SKPaymentQueue.canMakePayments() + init(productsInfoController: ProductsInfoController = ProductsInfoController(), + paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default())) { + + self.productsInfoController = productsInfoController + self.paymentQueueController = paymentQueueController } - // MARK: Public methods - Purchases - public class func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { - - return sharedInstance.productsInfoController.retrieveProductsInfo(productIds, completion: completion) + // MARK: Internal methods + + func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { + return productsInfoController.retrieveProductsInfo(productIds, completion: completion) } - /** - * Purchase a product - * - Parameter productId: productId as specified in iTunes Connect - * - 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 purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) { + func purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) { - if let product = sharedInstance.productsInfoController.products[productId] { - sharedInstance.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion) + if let product = productsInfoController.products[productId] { + purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion) } else { retrieveProductsInfo(Set([productId])) { result -> () in if let product = result.retrievedProducts.first { - sharedInstance.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion) + self.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion) } else if let error = result.error { completion(.error(error: .failed(error: error))) @@ -77,90 +70,30 @@ public class SwiftyStoreKit { } } - public class func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> ()) { - - sharedInstance.paymentQueueController.restorePurchases(RestorePurchases(atomically: atomically, applicationUsername: applicationUsername) { results in + func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> ()) { - let results = sharedInstance.processRestoreResults(results) + paymentQueueController.restorePurchases(RestorePurchases(atomically: atomically, applicationUsername: applicationUsername) { results in + + let results = self.processRestoreResults(results) completion(results) }) } - public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) { + func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) { - sharedInstance.paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion)) - } - - - public class func finishTransaction(_ transaction: PaymentTransaction) { - - sharedInstance.paymentQueueController.finishTransaction(transaction) - } - - // MARK: Public methods - Receipt verification - - /** - * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL - */ - public static var localReceiptData: Data? { - return InAppReceipt.appStoreReceiptData + paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion)) } - /** - * 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 class func verifyReceipt( - using validator: ReceiptValidator, - password: String? = nil, - completion:@escaping (VerifyReceiptResult) -> ()) { - - InAppReceipt.verify(using: validator, password: password) { result in - - DispatchQueue.main.async { - completion(result) - } - } - } - - /** - * 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 { - 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 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 - */ - public class func verifySubscription( - productId: String, - inReceipt receipt: ReceiptInfo, - validUntil date: Date = Date(), - validDuration duration: TimeInterval? = nil - ) -> VerifySubscriptionResult { - return InAppReceipt.verifySubscription(productId: productId, inReceipt: receipt, validUntil: date, validDuration: duration) + func finishTransaction(_ transaction: PaymentTransaction) { + + paymentQueueController.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) -> ()) { - sharedInstance.receiptRefreshRequest = InAppReceiptRefreshRequest.refresh(receiptProperties) { result in - - sharedInstance.receiptRefreshRequest = nil - + func refreshReceipt(_ receiptProperties: [String : Any]? = nil, completion: @escaping (RefreshReceiptResult) -> ()) { + receiptRefreshRequest = InAppReceiptRefreshRequest.refresh(receiptProperties) { result in + + self.receiptRefreshRequest = nil + switch result { case .success: if let appStoreReceiptData = InAppReceipt.appStoreReceiptData { @@ -175,6 +108,7 @@ public class SwiftyStoreKit { } } + // MARK: private methods private func purchase(product: SKProduct, atomically: Bool, applicationUsername: String = "", completion: @escaping (PurchaseResult) -> ()) { guard SwiftyStoreKit.canMakePayments else { @@ -219,3 +153,114 @@ public class SwiftyStoreKit { return NSError(domain: "SwiftyStoreKit", code: code, userInfo: [ NSLocalizedDescriptionKey: description ]) } } + +extension SwiftyStoreKit { + + // MARK: Singleton + private static let sharedInstance = SwiftyStoreKit() + + // MARK: Public methods - Purchases + public class var canMakePayments: Bool { + return SKPaymentQueue.canMakePayments() + } + + public class func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { + + return sharedInstance.retrieveProductsInfo(productIds, completion: completion) + } + + /** + * Purchase a product + * - Parameter productId: productId as specified in iTunes Connect + * - 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 purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) { + + sharedInstance.purchaseProduct(productId, atomically: atomically, applicationUsername: applicationUsername, completion: completion) + } + + public class func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> ()) { + + sharedInstance.restorePurchases(atomically: atomically, applicationUsername: applicationUsername, completion: completion) + } + + public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) { + + sharedInstance.completeTransactions(atomically: atomically, completion: completion) + } + + + 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) -> ()) { + + sharedInstance.refreshReceipt(receiptProperties, completion: completion) + } +} + +extension SwiftyStoreKit { + + // MARK: Public methods - Receipt verification + + /** + * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL + */ + public static var localReceiptData: Data? { + return InAppReceipt.appStoreReceiptData + } + + /** + * 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 class func verifyReceipt( + using validator: ReceiptValidator, + password: String? = nil, + completion:@escaping (VerifyReceiptResult) -> ()) { + + InAppReceipt.verify(using: validator, password: password) { result in + + DispatchQueue.main.async { + completion(result) + } + } + } + + /** + * 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 { + 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 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 + */ + public class func verifySubscription( + productId: String, + inReceipt receipt: ReceiptInfo, + validUntil date: Date = Date(), + validDuration duration: TimeInterval? = nil + ) -> VerifySubscriptionResult { + return InAppReceipt.verifySubscription(productId: productId, inReceipt: receipt, validUntil: date, validDuration: duration) + } +} From edc3b2a553a5bcf37d22d9d26c080ac7e81466fa Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 11:52:06 +0000 Subject: [PATCH 25/31] Perform InAppProductQueryRequest on calling thread --- SwiftyStoreKit/InAppProductQueryRequest.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/SwiftyStoreKit/InAppProductQueryRequest.swift b/SwiftyStoreKit/InAppProductQueryRequest.swift index f346224f..4614579d 100644 --- a/SwiftyStoreKit/InAppProductQueryRequest.swift +++ b/SwiftyStoreKit/InAppProductQueryRequest.swift @@ -48,14 +48,10 @@ class InAppProductQueryRequest: NSObject, SKProductsRequestDelegate { } func start() { - DispatchQueue.global(qos: .default).async { - self.request.start() - } + self.request.start() } func cancel() { - DispatchQueue.global(qos: .default).async { - self.request.cancel() - } + self.request.cancel() } // MARK: SKProductsRequestDelegate From 059873ba4c3fdfc0fb21c1b11129c6e91757cc39 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 11:52:14 +0000 Subject: [PATCH 26/31] Update documentation --- SwiftyStoreKit/SwiftyStoreKit.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SwiftyStoreKit/SwiftyStoreKit.swift b/SwiftyStoreKit/SwiftyStoreKit.swift index 3ec5dfbd..7f099f65 100644 --- a/SwiftyStoreKit/SwiftyStoreKit.swift +++ b/SwiftyStoreKit/SwiftyStoreKit.swift @@ -108,7 +108,6 @@ public class SwiftyStoreKit { } } - // MARK: private methods private func purchase(product: SKProduct, atomically: Bool, applicationUsername: String = "", completion: @escaping (PurchaseResult) -> ()) { guard SwiftyStoreKit.canMakePayments else { @@ -238,7 +237,7 @@ extension SwiftyStoreKit { * 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 + * - return: either notPurchased or purchased */ public class func verifyPurchase( productId: String, From 41d91102a14759460b0f49083adf33c846cd5086 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 11:52:50 +0000 Subject: [PATCH 27/31] Remove verifyReceipt code in app delegate --- SwiftyStoreKit-iOS-Demo/AppDelegate.swift | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/SwiftyStoreKit-iOS-Demo/AppDelegate.swift b/SwiftyStoreKit-iOS-Demo/AppDelegate.swift index 3a77bef6..fce9634b 100644 --- a/SwiftyStoreKit-iOS-Demo/AppDelegate.swift +++ b/SwiftyStoreKit-iOS-Demo/AppDelegate.swift @@ -32,28 +32,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool { - verifyReceipt() - completeIAPTransactions() return true } - func verifyReceipt() { - - let appleValidator = AppleReceiptValidator(service: .production) - SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in - switch result { - case .success(let receipt): - print("\(receipt)") - case .error(let error): - if case .noReceiptData = error { - SwiftyStoreKit.refreshReceipt { result in } - } - } - } - } - func completeIAPTransactions() { SwiftyStoreKit.completeTransactions(atomically: true) { products in From 74c700e7cf6b7f33fc2c3e0a75096f2e563890b1 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 15:03:16 +0000 Subject: [PATCH 28/31] Add unit tests target to build script --- scripts/build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/build.sh b/scripts/build.sh index 282e34ef..c263a6d8 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -3,3 +3,5 @@ xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_iOS xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_macOS xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_tvOS + +xcodebuild test -project SwiftyStoreKit.xcodeproj -scheme SwiftyStoreKitTests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 6,OS=9.3' From 294293a2b095265f73a6c18460409881846ad552 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 16:15:41 +0000 Subject: [PATCH 29/31] Make all new payment classes internal rather than public as they are not designed to be used outside SwiftyStoreKit --- .../CompleteTransactionsController.swift | 16 +++++------- SwiftyStoreKit/PaymentQueueController.swift | 25 +++++++++--------- SwiftyStoreKit/PaymentsController.swift | 26 +++++++++---------- .../RestorePurchasesController.swift | 23 +++++++--------- .../CompleteTransactionsControllerTests.swift | 2 +- .../PaymentQueueControllerTests.swift | 4 +-- .../PaymentsControllerTests.swift | 2 +- .../RestorePurchasesControllerTests.swift | 2 +- 8 files changed, 46 insertions(+), 54 deletions(-) diff --git a/SwiftyStoreKit/CompleteTransactionsController.swift b/SwiftyStoreKit/CompleteTransactionsController.swift index 86039796..5dbd521e 100644 --- a/SwiftyStoreKit/CompleteTransactionsController.swift +++ b/SwiftyStoreKit/CompleteTransactionsController.swift @@ -25,11 +25,11 @@ import Foundation import StoreKit -public struct CompleteTransactions { - public let atomically: Bool - public let callback: ([Product]) -> () +struct CompleteTransactions { + let atomically: Bool + let callback: ([Product]) -> () - public init(atomically: Bool, callback: @escaping ([Product]) -> ()) { + init(atomically: Bool, callback: @escaping ([Product]) -> ()) { self.atomically = atomically self.callback = callback } @@ -49,13 +49,11 @@ extension SKPaymentTransactionState { } -public class CompleteTransactionsController: TransactionController { +class CompleteTransactionsController: TransactionController { - public var completeTransactions: CompleteTransactions? + var completeTransactions: CompleteTransactions? - public init() {} - - public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { guard let completeTransactions = completeTransactions else { return transactions diff --git a/SwiftyStoreKit/PaymentQueueController.swift b/SwiftyStoreKit/PaymentQueueController.swift index d99bdb87..a70f28b9 100644 --- a/SwiftyStoreKit/PaymentQueueController.swift +++ b/SwiftyStoreKit/PaymentQueueController.swift @@ -25,8 +25,7 @@ import Foundation import StoreKit - -public protocol TransactionController { +protocol TransactionController { /** * - param transactions: transactions to process @@ -56,7 +55,7 @@ public protocol PaymentQueue: class { extension SKPaymentQueue: PaymentQueue { } -public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { +class PaymentQueueController: NSObject, SKPaymentTransactionObserver { private let paymentsController: PaymentsController @@ -70,7 +69,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { paymentQueue.remove(self) } - public init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), + init(paymentQueue: PaymentQueue = SKPaymentQueue.default(), paymentsController: PaymentsController = PaymentsController(), restorePurchasesController: RestorePurchasesController = RestorePurchasesController(), completeTransactionsController: CompleteTransactionsController = CompleteTransactionsController()) { @@ -83,7 +82,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { paymentQueue.add(self) } - public func startPayment(_ payment: Payment) { + func startPayment(_ payment: Payment) { let skPayment = SKMutablePayment(product: payment.product) skPayment.applicationUsername = payment.applicationUsername @@ -92,7 +91,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { paymentsController.append(payment) } - public func restorePurchases(_ restorePurchases: RestorePurchases) { + func restorePurchases(_ restorePurchases: RestorePurchases) { if restorePurchasesController.restorePurchases != nil { // return .inProgress @@ -104,12 +103,12 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { restorePurchasesController.restorePurchases = restorePurchases } - public func completeTransactions(_ completeTransactions: CompleteTransactions) { + func completeTransactions(_ completeTransactions: CompleteTransactions) { completeTransactionsController.completeTransactions = completeTransactions } - public func finishTransaction(_ transaction: PaymentTransaction) { + func finishTransaction(_ transaction: PaymentTransaction) { guard let skTransaction = transaction as? SKPaymentTransaction else { print("Object is not a SKPaymentTransaction: \(transaction)") return @@ -119,7 +118,7 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { // MARK: SKPaymentTransactionObserver - public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { /* * Some notes about how requests are processed by SKPaymentQueue: @@ -153,21 +152,21 @@ public class PaymentQueueController: NSObject, SKPaymentTransactionObserver { } } - public func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { + func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { } - public func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { + func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { restorePurchasesController.restoreCompletedTransactionsFailed(withError: error) } - public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { + func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { restorePurchasesController.restoreCompletedTransactionsFinished() } - public func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) { + func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) { } diff --git a/SwiftyStoreKit/PaymentsController.swift b/SwiftyStoreKit/PaymentsController.swift index 337593f2..13505ae6 100644 --- a/SwiftyStoreKit/PaymentsController.swift +++ b/SwiftyStoreKit/PaymentsController.swift @@ -26,26 +26,24 @@ import Foundation import StoreKit -public struct Payment: Hashable { - public let product: SKProduct - public let atomically: Bool - public let applicationUsername: String - public let callback: (TransactionResult) -> () +struct Payment: Hashable { + let product: SKProduct + let atomically: Bool + let applicationUsername: String + let callback: (TransactionResult) -> () - public var hashValue: Int { + var hashValue: Int { return product.productIdentifier.hashValue } - public static func ==(lhs: Payment, rhs: Payment) -> Bool { + static func ==(lhs: Payment, rhs: Payment) -> Bool { return lhs.product.productIdentifier == rhs.product.productIdentifier } } -public class PaymentsController: TransactionController { +class PaymentsController: TransactionController { private var payments: [Payment] = [] - public init() { } - private func findPaymentIndex(withProductIdentifier identifier: String) -> Int? { for payment in payments { if payment.product.productIdentifier == identifier { @@ -55,15 +53,15 @@ public class PaymentsController: TransactionController { return nil } - public func hasPayment(_ payment: Payment) -> Bool { + func hasPayment(_ payment: Payment) -> Bool { return findPaymentIndex(withProductIdentifier: payment.product.productIdentifier) != nil } - public func append(_ payment: Payment) { + func append(_ payment: Payment) { payments.append(payment) } - public func processTransaction(_ transaction: SKPaymentTransaction, on paymentQueue: PaymentQueue) -> Bool { + func processTransaction(_ transaction: SKPaymentTransaction, on paymentQueue: PaymentQueue) -> Bool { let transactionProductIdentifier = transaction.payment.productIdentifier @@ -104,7 +102,7 @@ public class PaymentsController: TransactionController { return false } - public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { return transactions.filter { !processTransaction($0, on: paymentQueue) } } diff --git a/SwiftyStoreKit/RestorePurchasesController.swift b/SwiftyStoreKit/RestorePurchasesController.swift index 4aa24f31..67607813 100644 --- a/SwiftyStoreKit/RestorePurchasesController.swift +++ b/SwiftyStoreKit/RestorePurchasesController.swift @@ -25,28 +25,25 @@ import Foundation import StoreKit -public struct RestorePurchases { - public let atomically: Bool - public let applicationUsername: String? - public let callback: ([TransactionResult]) -> () +struct RestorePurchases { + let atomically: Bool + let applicationUsername: String? + let callback: ([TransactionResult]) -> () - public init(atomically: Bool, applicationUsername: String? = nil, callback: @escaping ([TransactionResult]) -> ()) { + init(atomically: Bool, applicationUsername: String? = nil, callback: @escaping ([TransactionResult]) -> ()) { self.atomically = atomically self.applicationUsername = applicationUsername self.callback = callback } } - -public class RestorePurchasesController: TransactionController { +class RestorePurchasesController: TransactionController { public var restorePurchases: RestorePurchases? private var restoredProducts: [TransactionResult] = [] - public init() { } - - public func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? { + func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? { let transactionState = transaction.transactionState @@ -63,7 +60,7 @@ public class RestorePurchasesController: TransactionController { return nil } - public func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { + func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] { guard let restorePurchases = restorePurchases else { return transactions @@ -82,7 +79,7 @@ public class RestorePurchasesController: TransactionController { return unhandledTransactions } - public func restoreCompletedTransactionsFailed(withError error: Error) { + func restoreCompletedTransactionsFailed(withError error: Error) { guard let restorePurchases = restorePurchases else { return @@ -96,7 +93,7 @@ public class RestorePurchasesController: TransactionController { } - public func restoreCompletedTransactionsFinished() { + func restoreCompletedTransactionsFinished() { guard let restorePurchases = restorePurchases else { return diff --git a/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift b/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift index 30b30d4f..5820143f 100644 --- a/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift +++ b/SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift @@ -23,8 +23,8 @@ // THE SOFTWARE. import XCTest -import SwiftyStoreKit import StoreKit +@testable import SwiftyStoreKit class CompleteTransactionsControllerTests: XCTestCase { diff --git a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift index 8dd91bb4..a39feb78 100644 --- a/SwiftyStoreKitTests/PaymentQueueControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentQueueControllerTests.swift @@ -23,11 +23,11 @@ // THE SOFTWARE. import XCTest -import SwiftyStoreKit import StoreKit +@testable import SwiftyStoreKit extension Payment { - public init(product: SKProduct, atomically: Bool, applicationUsername: String, callback: @escaping (TransactionResult) -> ()) { + init(product: SKProduct, atomically: Bool, applicationUsername: String, callback: @escaping (TransactionResult) -> ()) { self.product = product self.atomically = atomically self.applicationUsername = applicationUsername diff --git a/SwiftyStoreKitTests/PaymentsControllerTests.swift b/SwiftyStoreKitTests/PaymentsControllerTests.swift index 27a3311e..bba9c378 100644 --- a/SwiftyStoreKitTests/PaymentsControllerTests.swift +++ b/SwiftyStoreKitTests/PaymentsControllerTests.swift @@ -23,8 +23,8 @@ // THE SOFTWARE. import XCTest -import SwiftyStoreKit import StoreKit +@testable import SwiftyStoreKit class PaymentsControllerTests: XCTestCase { diff --git a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift index 9dd518c1..25fcf627 100644 --- a/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift +++ b/SwiftyStoreKitTests/RestorePurchasesControllerTests.swift @@ -24,8 +24,8 @@ import XCTest -import SwiftyStoreKit import StoreKit +@testable import SwiftyStoreKit class RestorePurchasesControllerTests: XCTestCase { From 267dbd67c8e79e9ac7c21097b30b5066cfc3d007 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sat, 21 Jan 2017 18:18:16 +0000 Subject: [PATCH 30/31] Documented new payment flows in README --- README.md | 69 ++++++++++++++++++------------------------------------- 1 file changed, 22 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 95ad011f..c8c3254d 100644 --- a/README.md +++ b/README.md @@ -308,33 +308,12 @@ The project includes demo apps [for iOS](https://github.com/bizz84/SwiftyStoreKi Note that the pre-registered in app purchases in the demo apps are for illustration purposes only and may not work as iTunes Connect may invalidate them. #### Features + - Super easy to use block based API - Support for consumable, non-consumable in-app purchases - Support for free, auto renewable and non renewing subscriptions - Receipt verification - iOS, tvOS and macOS compatible -- enum-based error handling - -## Known issues - -#### Requests lifecycle - -While SwiftyStoreKit tries handle concurrent purchase or restore purchases requests, it is not guaranteed that this will always work flawlessly. -This is in part because using a closure-based API does not map perfectly well with the lifecycle of payments in `SKPaymentQueue`. - -In real applications the following could happen: - -1. User starts a purchase -2. User kills the app -3. OS continues processing this, resulting in a failed or successful purchase -4. App is restarted (payment queue is not updated yet) -5. User starts another purchase (the old transaction may interfere with the new purchase) - -To prevent situations like this from happening, a `completeTransactions()` method has been added in version 0.2.8. This should be called when the app starts as it can take care of clearing the payment queue and notifying the app of the transactions that have finished. - -#### Multiple accounts - -The user can background the hosting application and change the Apple ID used with the App Store, then foreground the app. This has been observed to cause problems with SwiftyStoreKit - other IAP implementations may suffer from this as well. ## Essential Reading * [Apple - WWDC16, Session 702: Using Store Kit for In-app Purchases with Swift 3](https://developer.apple.com/videos/play/wwdc2016/702/) @@ -346,45 +325,41 @@ The user can background the hosting application and change the Apple ID used wit * [objc.io - Receipt Validation](https://www.objc.io/issues/17-security/receipt-validation/) -## Implementation Details +## Payment flows - implementation Details In order to make a purchase, two operations are needed: -- Obtain the ```SKProduct``` corresponding to the productId that identifies the app purchase, via ```SKProductRequest```. +- Perform a `SKProductRequest` to obtain the `SKProduct` corresponding to the product identifier. -- Submit the payment for that product via ```SKPaymentQueue```. +- Submit the payment and listen for updated transactions on the `SKPaymentQueue`. The framework takes care of caching SKProducts so that future requests for the same ```SKProduct``` don't need to perform a new ```SKProductRequest```. -### Requesting products information +### Payment queue -SwiftyStoreKit wraps the delegate-based ```SKProductRequest``` API with a block based class named ```InAppProductQueryRequest```, which returns a `RetrieveResults` value with information about the obtained products: +The following list outlines how requests are processed by SwiftyStoreKit. -```swift -public struct RetrieveResults { - public let retrievedProducts: Set - public let invalidProductIDs: Set - public let error: NSError? -} -``` -This value is then surfaced back to the caller of the `retrieveProductsInfo()` method the completion closure so that the client can update accordingly. +* `SKPaymentQueue` is used to queue payments or restore purchases requests. +* Payments are processed serially and in-order and require user interaction. +* Restore purchases requests don't require user interaction and can jump ahead of the queue. +* `SKPaymentQueue` rejects multiple restore purchases calls. +* Failed translations only ever belong to queued payment request. +* `restoreCompletedTransactionsFailedWithError` is always called when a restore purchases request fails. +* `paymentQueueRestoreCompletedTransactionsFinished` is always called following 0 or more update transactions when a restore purchases request succeeds. +* A complete transactions handler is require to catch any transactions that are updated when the app is not running. +* Registering a complete transactions handler when the app launches ensures that any pending transactions can be cleared. +* If a complete transactions handler is missing, pending transactions can be mis-attributed to any new incoming payments or restore purchases. -### Purchasing a product / Restoring purchases -`InAppProductPurchaseRequest` is a wrapper class for `SKPaymentQueue` that can be used to purchase a product or restore purchases. +The order in which transaction updates are processed is: -The class conforms to the `SKPaymentTransactionObserver` protocol in order to receive transactions notifications from the payment queue. The following outcomes are defined for a purchase/restore action: +1. payments (transactionState: `.purchased` and `.failed` for matching product identifiers) +2. restore purchases (transactionState: `.restored`, or `restoreCompletedTransactionsFailedWithError`, or `paymentQueueRestoreCompletedTransactionsFinished`) +3. complete transactions (transactionState: `.purchased`, `.failed`, `.restored`, `.deferred`) -```swift -enum TransactionResult { - case purchased(productId: String) - case restored(productId: String) - case failed(error: NSError) -} -``` -Depending on the operation, the completion closure for `InAppProductPurchaseRequest` is then mapped to either a `PurchaseResult` or a `RestoreResults` value and returned to the caller. +Any transactions where state == `.purchasing` are ignored. ## Contributing -[Read here](CONTRIBUTING.md) +[Read here](CONTRIBUTING.md). ## Credits Many thanks to [phimage](https://github.com/phimage) for adding macOS support and receipt verification. From 8e07fc5cdf6b05c4e76c74f0f851df75307cee0e Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Sun, 29 Jan 2017 20:25:12 +0000 Subject: [PATCH 31/31] Refactor ProductsInfoController to use more functional syle --- SwiftyStoreKit/ProductsInfoController.swift | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/SwiftyStoreKit/ProductsInfoController.swift b/SwiftyStoreKit/ProductsInfoController.swift index 4095fe11..198cc5a3 100644 --- a/SwiftyStoreKit/ProductsInfoController.swift +++ b/SwiftyStoreKit/ProductsInfoController.swift @@ -38,18 +38,9 @@ class ProductsInfoController: NSObject { products[product.productIdentifier] = product } - private func allProductsMatching(_ productIds: Set) -> Set? { + private func allProductsMatching(_ productIds: Set) -> Set { - var requestedProducts = Set() - - for productId in productIds { - - guard let product = products[productId] else { - return nil - } - requestedProducts.insert(product) - } - return requestedProducts + return Set(productIds.flatMap { self.products[$0] }) } private func requestProducts(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { @@ -66,7 +57,8 @@ class ProductsInfoController: NSObject { func retrieveProductsInfo(_ productIds: Set, completion: @escaping (RetrieveResults) -> ()) { - guard let products = allProductsMatching(productIds) else { + let products = allProductsMatching(productIds) + guard products.count == productIds.count else { requestProducts(productIds, completion: completion) return