diff --git a/.jazzy.yaml b/.jazzy.yaml index 9ad984b1e3..83d6836ad4 100644 --- a/.jazzy.yaml +++ b/.jazzy.yaml @@ -5,9 +5,9 @@ objc: true sdk: iphonesimulator module: Purchases umbrella_header: Purchases/Public/Purchases.h -module_version: 3.6.0-beta-2 +module_version: 3.6.0 github_url: https://github.com/revenuecat/purchases-ios -github_file_prefix: https://github.com/revenuecat/purchases-ios/tree/3.6.0-beta-2 +github_file_prefix: https://github.com/revenuecat/purchases-ios/tree/3.6.0 output: docs # Leaving this commented out. We used to specify this before, but now it's working without it # xcodebuild_arguments: [--objc,Purchases/Public/Purchases.h,--,-x,objective-c,-isysroot,$(xcrun --show-sdk-path),-I,$(pwd)] diff --git a/CHANGELOG.md b/CHANGELOG.md index 743b37b53d..116b5b4681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 3.6.0-beta-2 +## 3.6.0 - Fixed a race condition with purchase completed callbacks https://github.com/RevenueCat/purchases-ios/pull/313 - Made RCTransaction public to fix compiling issues on Swift Package Manager @@ -13,8 +13,6 @@ https://github.com/RevenueCat/purchases-ios/pull/320 - Added a local receipt parser, updated intro eligibility calculation to perform on device first https://github.com/RevenueCat/purchases-ios/pull/302 - -## 3.6.0-beta-1 - Fix crash when productIdentifier or payment is nil. https://github.com/RevenueCat/purchases-ios/pull/297 - Fixes ask-to-buy flow and will now send an error indicating there's a deferred payment. @@ -25,6 +23,10 @@ https://github.com/RevenueCat/purchases-ios/pull/287 - New properties added to the PurchaserInfo to better manage non-subscriptions. https://github.com/RevenueCat/purchases-ios/pull/281 +- Bypass workaround in watchOS 7 that fixes watchOS 6.2 bug where devices report wrong `appStoreReceiptURL` + https://github.com/RevenueCat/purchases-ios/pull/330 +- Fix bug where 404s in subscriber attributes POST would mark them as synced + https://github.com/RevenueCat/purchases-ios/pull/328 ## 3.5.2 - Feature/defer cache updates if woken from push notification diff --git a/Gemfile.lock b/Gemfile.lock index b7ddd73a0e..5c1ee20b6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,7 +14,7 @@ GEM json (>= 1.5.1) atomos (0.1.3) aws-eventstream (1.1.0) - aws-partitions (1.359.0) + aws-partitions (1.362.0) aws-sdk-core (3.105.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) @@ -23,7 +23,7 @@ GEM aws-sdk-kms (1.37.0) aws-sdk-core (~> 3, >= 3.99.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.79.0) + aws-sdk-s3 (1.79.1) aws-sdk-core (~> 3, >= 3.104.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) @@ -89,7 +89,7 @@ GEM faraday_middleware (1.0.0) faraday (~> 1.0) fastimage (2.2.0) - fastlane (2.156.1) + fastlane (2.157.2) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) aws-sdk-s3 (~> 1.0) @@ -144,7 +144,7 @@ GEM google-cloud-env (1.3.3) faraday (>= 0.17.3, < 2.0) google-cloud-errors (1.0.1) - google-cloud-storage (1.27.0) + google-cloud-storage (1.28.0) addressable (~> 2.5) digest-crc (~> 0.4) google-api-client (~> 0.33) diff --git a/Purchases.podspec b/Purchases.podspec index 28a0c595a8..70169fc7be 100644 --- a/Purchases.podspec +++ b/Purchases.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Purchases" - s.version = "3.6.0-beta-2" + s.version = "3.6.0" s.summary = "Subscription and in-app-purchase backend service." s.description = <<-DESC @@ -22,7 +22,7 @@ Pod::Spec.new do |s| s.tvos.deployment_target = '9.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.dependency 'PurchasesCoreSwift' + s.dependency 'PurchasesCoreSwift', '3.6.0' s.source_files = ['Purchases/**/*.{h,m}'] s.public_header_files = [ diff --git a/Purchases.xcodeproj/project.pbxproj b/Purchases.xcodeproj/project.pbxproj index fc67c658a2..5ff51805c3 100644 --- a/Purchases.xcodeproj/project.pbxproj +++ b/Purchases.xcodeproj/project.pbxproj @@ -201,6 +201,7 @@ 37E35F20B49BCE1B6D76B084 /* RCStoreKitRequestFetcher.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35804C14F5E6CEAF3909C /* RCStoreKitRequestFetcher.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35F20FB949985BEEB4B58 /* MockRequestFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35609E46E869675A466C1 /* MockRequestFetcher.swift */; }; 37E35F549AEB655AB6DA83B3 /* MockSKDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35EABF6D7AFE367718784 /* MockSKDiscount.swift */; }; + 37E35F67255A87BD86B39D43 /* MockReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3558F697A939D2BBD7FEC /* MockReceiptParser.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -374,6 +375,7 @@ 37E35548F15DE7CFFCE3AA8A /* NSLocale+RCExtensions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSLocale+RCExtensions.m"; sourceTree = ""; }; 37E3555B4BE0A4F7222E7B00 /* MockOfferingsFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockOfferingsFactory.swift; sourceTree = ""; }; 37E355744D64075AA91342DE /* MockInAppPurchaseBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockInAppPurchaseBuilder.swift; sourceTree = ""; }; + 37E3558F697A939D2BBD7FEC /* MockReceiptParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockReceiptParser.swift; sourceTree = ""; }; 37E355AE6CB674484555D1AC /* NSDate+RCExtensions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+RCExtensions.h"; sourceTree = ""; }; 37E355CBB3F3A31A32687B14 /* Transaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = ""; }; 37E35609E46E869675A466C1 /* MockRequestFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockRequestFetcher.swift; sourceTree = ""; }; @@ -784,6 +786,7 @@ 2DD7BA4C24C63A830066B4C2 /* MockSystemInfo.swift */, 37E35659EB530A5109AFAB50 /* MockOperationDispatcher.swift */, 2D4D6AF224F7172900B656BE /* MockProductsRequest.swift */, + 37E3558F697A939D2BBD7FEC /* MockReceiptParser.swift */, ); path = Mocks; sourceTree = ""; @@ -1428,6 +1431,7 @@ 37E35F0387D0ADE014186924 /* ProductInfoExtractorTests.swift in Sources */, 37E351505CB4764821451E27 /* ProductInfoExtensions.swift in Sources */, 37E3599326581376E0142EEC /* SystemInfoTests.swift in Sources */, + 37E35F67255A87BD86B39D43 /* MockReceiptParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Purchases/Misc/RCSystemInfo.m b/Purchases/Misc/RCSystemInfo.m index 8f69d7de16..0a31b249b8 100644 --- a/Purchases/Misc/RCSystemInfo.m +++ b/Purchases/Misc/RCSystemInfo.m @@ -47,7 +47,7 @@ + (BOOL)isSandbox { } + (NSString *)frameworkVersion { - return @"3.6.0-beta-2"; + return @"3.6.0"; } + (NSString *)systemVersion { diff --git a/Purchases/Networking/RCBackend.m b/Purchases/Networking/RCBackend.m index 0f61856130..8ad6cfafb4 100644 --- a/Purchases/Networking/RCBackend.m +++ b/Purchases/Networking/RCBackend.m @@ -479,8 +479,10 @@ - (void)handleSubscriberAttributesResultWithStatusCode:(NSInteger)statusCode - (NSDictionary *)attributesUserInfoFromResponse:(NSDictionary *)response statusCode:(NSInteger)statusCode { NSMutableDictionary *resultDict = [[NSMutableDictionary alloc] init]; BOOL isInternalServerError = statusCode >= RC_INTERNAL_SERVER_ERROR; - resultDict[RCSuccessfullySyncedKey] = @(!isInternalServerError); - + BOOL isNotFoundError = statusCode == RC_NOT_FOUND_ERROR; + BOOL successfullySynced = !(isInternalServerError || isNotFoundError); + resultDict[RCSuccessfullySyncedKey] = @(successfullySynced); + BOOL hasAttributesResponseContainerKey = (response[RCAttributeErrorsResponseKey] != nil); NSDictionary *attributesResponseDict = hasAttributesResponseContainerKey ? response[RCAttributeErrorsResponseKey] diff --git a/Purchases/Networking/RCHTTPStatusCodes.h b/Purchases/Networking/RCHTTPStatusCodes.h index b7394958b7..fac144e82d 100644 --- a/Purchases/Networking/RCHTTPStatusCodes.h +++ b/Purchases/Networking/RCHTTPStatusCodes.h @@ -8,6 +8,7 @@ NS_ASSUME_NONNULL_BEGIN typedef NS_ENUM(NSUInteger, RCHTTPStatusCodes) { RC_REDIRECT = 300, + RC_NOT_FOUND_ERROR = 404, RC_INTERNAL_SERVER_ERROR = 500, RC_NETWORK_CONNECT_TIMEOUT_ERROR = 599 }; diff --git a/Purchases/ProtectedExtensions/RCPurchases+Protected.h b/Purchases/ProtectedExtensions/RCPurchases+Protected.h index dd6e6496d1..0c15ee0f2b 100644 --- a/Purchases/ProtectedExtensions/RCPurchases+Protected.h +++ b/Purchases/ProtectedExtensions/RCPurchases+Protected.h @@ -19,7 +19,8 @@ RCSubscriberAttributesManager, RCSystemInfo, RCOperationDispatcher, - RCIntroEligibilityCalculator; + RCIntroEligibilityCalculator, + RCReceiptParser; NS_ASSUME_NONNULL_BEGIN @@ -40,7 +41,8 @@ NS_ASSUME_NONNULL_BEGIN identityManager:(RCIdentityManager *)identityManager subscriberAttributesManager:(RCSubscriberAttributesManager *)subscriberAttributesManager operationDispatcher:(RCOperationDispatcher *)operationDispatcher - introEligibilityCalculator:(RCIntroEligibilityCalculator *)introEligibilityCalculator; + introEligibilityCalculator:(RCIntroEligibilityCalculator *)introEligibilityCalculator + receiptParser:(RCReceiptParser *)receiptParser; + (void)setDefaultInstance:(nullable RCPurchases *)instance; diff --git a/Purchases/Public/RCPurchases.m b/Purchases/Public/RCPurchases.m index 7419a531d5..d8011d6903 100644 --- a/Purchases/Public/RCPurchases.m +++ b/Purchases/Public/RCPurchases.m @@ -64,8 +64,8 @@ @interface RCPurchases () { @property (nonatomic) RCDeviceCache *deviceCache; @property (nonatomic) RCIdentityManager *identityManager; @property (nonatomic) RCSystemInfo *systemInfo; -@property (nonatomic) RCOperationDispatcher *operationDispatcher; @property (nonatomic) RCIntroEligibilityCalculator *introEligibilityCalculator; +@property (nonatomic) RCReceiptParser *receiptParser; @end @@ -225,6 +225,7 @@ - (instancetype)initWithAPIKey:(NSString *)APIKey deviceCache:deviceCache]; RCOperationDispatcher *operationDispatcher = [[RCOperationDispatcher alloc] init]; RCIntroEligibilityCalculator *introCalculator = [[RCIntroEligibilityCalculator alloc] init]; + RCReceiptParser *receiptParser = [[RCReceiptParser alloc] init]; return [self initWithAppUserID:appUserID requestFetcher:fetcher @@ -240,7 +241,8 @@ - (instancetype)initWithAPIKey:(NSString *)APIKey identityManager:identityManager subscriberAttributesManager:subscriberAttributesManager operationDispatcher:operationDispatcher - introEligibilityCalculator:introCalculator]; + introEligibilityCalculator:introCalculator + receiptParser:receiptParser]; } - (instancetype)initWithAppUserID:(nullable NSString *)appUserID @@ -257,7 +259,8 @@ - (instancetype)initWithAppUserID:(nullable NSString *)appUserID identityManager:(RCIdentityManager *)identityManager subscriberAttributesManager:(RCSubscriberAttributesManager *)subscriberAttributesManager operationDispatcher:(RCOperationDispatcher *)operationDispatcher - introEligibilityCalculator:(RCIntroEligibilityCalculator *)introEligibilityCalculator { + introEligibilityCalculator:(RCIntroEligibilityCalculator *)introEligibilityCalculator + receiptParser:(RCReceiptParser *)receiptParser { if (self = [super init]) { RCDebugLog(@"Debug logging enabled."); RCDebugLog(@"SDK Version - %@", self.class.frameworkVersion); @@ -283,6 +286,7 @@ - (instancetype)initWithAppUserID:(nullable NSString *)appUserID self.subscriberAttributesManager = subscriberAttributesManager; self.operationDispatcher = operationDispatcher; self.introEligibilityCalculator = introEligibilityCalculator; + self.receiptParser = receiptParser; RCReceivePurchaserInfoBlock callDelegate = ^void(RCPurchaserInfo *info, NSError *error) { if (info) { @@ -625,6 +629,15 @@ - (void)restoreTransactionsWithCompletionBlock:(nullable RCReceivePurchaserInfoB CALL_IF_SET_ON_MAIN_THREAD(completion, nil, [RCPurchasesErrorUtils missingReceiptFileError]); return; } + + RCPurchaserInfo * _Nullable cachedPurchaserInfo = [self readPurchaserInfoFromCache]; + BOOL hasOriginalPurchaseDate = cachedPurchaserInfo != nil && cachedPurchaserInfo.originalPurchaseDate != nil; + BOOL receiptHasTransactions = [self.receiptParser receiptHasTransactionsWithReceiptData:data]; + if (!receiptHasTransactions && hasOriginalPurchaseDate) { + CALL_IF_SET_ON_MAIN_THREAD(completion, cachedPurchaserInfo, nil); + return; + } + RCSubscriberAttributeDict subscriberAttributes = self.unsyncedAttributesByKey; [self.backend postReceiptData:data appUserID:self.appUserID diff --git a/Purchases/Purchasing/RCReceiptFetcher.m b/Purchases/Purchasing/RCReceiptFetcher.m index eec0e3a0fa..c37cbf5651 100644 --- a/Purchases/Purchasing/RCReceiptFetcher.m +++ b/Purchases/Purchasing/RCReceiptFetcher.m @@ -22,7 +22,9 @@ - (NSData *)receiptData { // correct receipt. // This has been filed as radar FB7699277. More info in https://github.com/RevenueCat/purchases-ios/issues/207. - if (RCSystemInfo.isSandbox) { + NSOperatingSystemVersion minimumOSVersionWithoutBug = { .majorVersion = 7, .minorVersion = 0, .patchVersion = 0 }; + BOOL isBelowMinimumOSVersionWithoutBug = ![NSProcessInfo.processInfo isOperatingSystemAtLeastVersion:minimumOSVersionWithoutBug]; + if (isBelowMinimumOSVersionWithoutBug && RCSystemInfo.isSandbox) { NSString *receiptURLFolder = [[receiptURL absoluteString] stringByDeletingLastPathComponent]; NSURL *productionReceiptURL = [NSURL URLWithString:[receiptURLFolder stringByAppendingPathComponent:@"receipt"]]; receiptURL = productionReceiptURL; diff --git a/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.h b/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.h index 2837d5b305..a1a93daa30 100644 --- a/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.h +++ b/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.h @@ -7,7 +7,7 @@ #import "RCPurchases.h" #import "RCSubscriberAttribute.h" -@class RCSubscriberAttribute, RCSubscriberAttributesManager; +@class RCSubscriberAttribute, RCSubscriberAttributesManager, RCOperationDispatcher; NS_ASSUME_NONNULL_BEGIN @@ -31,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN @interface RCPurchases () @property (nonatomic) RCSubscriberAttributesManager *subscriberAttributesManager; +@property (nonatomic) RCOperationDispatcher *operationDispatcher; @end diff --git a/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.m b/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.m index 690c93fdf3..aea5d765b0 100644 --- a/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.m +++ b/Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.m @@ -9,6 +9,7 @@ #import "RCCrossPlatformSupport.h" #import "RCLogUtils.h" #import "NSError+RCExtensions.h" +@import PurchasesCoreSwift; NS_ASSUME_NONNULL_BEGIN @@ -86,7 +87,9 @@ - (void)subscribeToAppBackgroundedNotifications { } - (void)syncSubscriberAttributesIfNeeded { - [self.subscriberAttributesManager syncAttributesForAllUsersWithCurrentAppUserID:self.appUserID]; + [self.operationDispatcher dispatchOnWorkerThread:^{ + [self.subscriberAttributesManager syncAttributesForAllUsersWithCurrentAppUserID:self.appUserID]; + }]; } @end diff --git a/PurchasesCoreSwift.podspec b/PurchasesCoreSwift.podspec index 2a4bcaa7c9..82a26974a6 100644 --- a/PurchasesCoreSwift.podspec +++ b/PurchasesCoreSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "PurchasesCoreSwift" - s.version = "3.6.0-beta-2" + s.version = "3.6.0" s.summary = "Swift portion of RevenueCat's Subscription and in-app-purchase backend service." s.description = <<-DESC diff --git a/PurchasesCoreSwift/LocalReceiptParsing/ReceiptParser.swift b/PurchasesCoreSwift/LocalReceiptParsing/ReceiptParser.swift index 5f76271a32..a9c124ed61 100644 --- a/PurchasesCoreSwift/LocalReceiptParsing/ReceiptParser.swift +++ b/PurchasesCoreSwift/LocalReceiptParsing/ReceiptParser.swift @@ -8,17 +8,32 @@ import Foundation -class ReceiptParser { - private let objectIdentifierParser: ASN1ObjectIdentifierBuilder +@objc(RCReceiptParser) public class ReceiptParser: NSObject { + private let objectIdentifierBuilder: ASN1ObjectIdentifierBuilder private let containerBuilder: ASN1ContainerBuilder private let receiptBuilder: AppleReceiptBuilder - init(objectIdentifierParser: ASN1ObjectIdentifierBuilder = ASN1ObjectIdentifierBuilder(), - containerBuilder: ASN1ContainerBuilder = ASN1ContainerBuilder(), - receiptBuilder: AppleReceiptBuilder = AppleReceiptBuilder()) { - self.objectIdentifierParser = objectIdentifierParser + @objc public convenience override init() { + self.init(objectIdentifierBuilder: ASN1ObjectIdentifierBuilder(), + containerBuilder: ASN1ContainerBuilder(), + receiptBuilder: AppleReceiptBuilder()) + } + + init(objectIdentifierBuilder: ASN1ObjectIdentifierBuilder, + containerBuilder: ASN1ContainerBuilder, + receiptBuilder: AppleReceiptBuilder) { + self.objectIdentifierBuilder = objectIdentifierBuilder self.containerBuilder = containerBuilder self.receiptBuilder = receiptBuilder + super.init() + } + + @objc public func receiptHasTransactions(receiptData: Data) -> Bool { + if let receipt = try? parse(from: receiptData) { + return receipt.inAppPurchases.count > 0 + } + // if the receipt can't be parsed, conservatively return true + return true } func parse(from receiptData: Data) throws -> AppleReceipt { @@ -40,7 +55,7 @@ private extension ReceiptParser { if container.encodingType == .constructed { for (index, internalContainer) in container.internalContainers.enumerated() { if internalContainer.containerIdentifier == .objectIdentifier { - let objectIdentifier = objectIdentifierParser.build(fromPayload: internalContainer.internalPayload) + let objectIdentifier = objectIdentifierBuilder.build(fromPayload: internalContainer.internalPayload) if objectIdentifier == objectId && index < container.internalContainers.count - 1 { // the container that holds the data comes right after the one with the object identifier return container.internalContainers[index + 1] diff --git a/PurchasesCoreSwiftTests/LocalReceiptParsing/ReceiptParserTests.swift b/PurchasesCoreSwiftTests/LocalReceiptParsing/ReceiptParserTests.swift index 89ddad2ab3..93f1181bd2 100644 --- a/PurchasesCoreSwiftTests/LocalReceiptParsing/ReceiptParserTests.swift +++ b/PurchasesCoreSwiftTests/LocalReceiptParsing/ReceiptParserTests.swift @@ -14,7 +14,8 @@ class ReceiptParserTests: XCTestCase { super.setUp() mockAppleReceiptBuilder = MockAppleReceiptBuilder() mockASN1ContainerBuilder = MockASN1ContainerBuilder() - receiptParser = ReceiptParser(containerBuilder: mockASN1ContainerBuilder, + receiptParser = ReceiptParser(objectIdentifierBuilder: ASN1ObjectIdentifierBuilder(), + containerBuilder: mockASN1ContainerBuilder, receiptBuilder: mockAppleReceiptBuilder) } @@ -27,7 +28,7 @@ class ReceiptParserTests: XCTestCase { ]) mockASN1ContainerBuilder.stubbedBuildResult = constructedContainer - let expectedReceipt = mockAppleReceipt() + let expectedReceipt = mockAppleReceiptWithoutPurchases() mockAppleReceiptBuilder.stubbedBuildResult = expectedReceipt let receivedReceipt = try! self.receiptParser.parse(from: Data()) @@ -63,7 +64,7 @@ class ReceiptParserTests: XCTestCase { ]) mockASN1ContainerBuilder.stubbedBuildResult = complexContainer - let expectedReceipt = mockAppleReceipt() + let expectedReceipt = mockAppleReceiptWithoutPurchases() mockAppleReceiptBuilder.stubbedBuildResult = expectedReceipt let receivedReceipt = try! self.receiptParser.parse(from: Data()) @@ -105,6 +106,23 @@ class ReceiptParserTests: XCTestCase { expect { try self.receiptParser.parse(from: Data()) } .to(throwError(ReceiptReadingError.dataObjectIdentifierMissing)) } + + func testReceiptHasTransactionsTrueIfReceiptHasTransactions() { + mockASN1ContainerBuilder.stubbedBuildResult = containerWithDataObjectIdentifier() + mockAppleReceiptBuilder.stubbedBuildResult = mockAppleReceiptWithPurchases() + expect(self.receiptParser.receiptHasTransactions(receiptData: Data())) == true + } + + func testReceiptHasTransactionsFalseIfNoIAPsInReceipt() { + mockASN1ContainerBuilder.stubbedBuildResult = containerWithDataObjectIdentifier() + mockAppleReceiptBuilder.stubbedBuildResult = mockAppleReceiptWithoutPurchases() + expect(self.receiptParser.receiptHasTransactions(receiptData: Data())) == false + } + + func testReceiptHasTransactionsTrueIfReceiptCantBeParsed() { + mockASN1ContainerBuilder.stubbedBuildError = ReceiptReadingError.receiptParsingError + expect(self.receiptParser.receiptHasTransactions(receiptData: Data())) == true + } } private extension ReceiptParserTests { @@ -118,7 +136,7 @@ private extension ReceiptParserTests { return constructedContainer } - func mockAppleReceipt() -> AppleReceipt { + func mockAppleReceiptWithoutPurchases() -> AppleReceipt { return AppleReceipt(bundleId: "com.revenuecat.testapp", applicationVersion: "3.2.3", originalApplicationVersion: "3.1.1", @@ -128,4 +146,42 @@ private extension ReceiptParserTests { expirationDate: nil, inAppPurchases: []) } + + func mockAppleReceiptWithPurchases() -> AppleReceipt { + return AppleReceipt(bundleId: "com.revenuecat.testapp", + applicationVersion: "3.2.3", + originalApplicationVersion: "3.1.1", + opaqueValue: Data(), + sha1Hash: Data(), + creationDate: Date(), + expirationDate: nil, + inAppPurchases: [ + InAppPurchase(quantity: 1, + productId: "com.revenuecat.test", + transactionId: "892398531", + originalTransactionId: "892398531", + productType: .autoRenewableSubscription, + purchaseDate: Date(), + originalPurchaseDate: Date(), + expiresDate: nil, + cancellationDate: Date(), + isInTrialPeriod: false, + isInIntroOfferPeriod: false, + webOrderLineItemId: 79238531, + promotionalOfferIdentifier: nil), + InAppPurchase(quantity: 1, + productId: "com.revenuecat.test", + transactionId: "892398532", + originalTransactionId: "892398531", + productType: .autoRenewableSubscription, + purchaseDate: Date(), + originalPurchaseDate: Date(), + expiresDate: nil, + cancellationDate: Date(), + isInTrialPeriod: false, + isInIntroOfferPeriod: false, + webOrderLineItemId: 79238532, + promotionalOfferIdentifier: nil) + ]) + } } diff --git a/PurchasesCoreSwiftTests/Mocks/MockReceiptParser.swift b/PurchasesCoreSwiftTests/Mocks/MockReceiptParser.swift index 3f6ca200cd..109d84c2bf 100644 --- a/PurchasesCoreSwiftTests/Mocks/MockReceiptParser.swift +++ b/PurchasesCoreSwiftTests/Mocks/MockReceiptParser.swift @@ -22,6 +22,12 @@ class MockReceiptParser: ReceiptParser { expirationDate: nil, inAppPurchases: []) + convenience init() { + self.init(objectIdentifierBuilder: ASN1ObjectIdentifierBuilder(), + containerBuilder: MockASN1ContainerBuilder(), + receiptBuilder: MockAppleReceiptBuilder()) + } + override func parse(from receiptData: Data) throws -> AppleReceipt { invokedParse = true invokedParseCount += 1 diff --git a/PurchasesTests/Mocks/MockBackend.swift b/PurchasesTests/Mocks/MockBackend.swift index 449a9b4221..080b787d38 100644 --- a/PurchasesTests/Mocks/MockBackend.swift +++ b/PurchasesTests/Mocks/MockBackend.swift @@ -61,12 +61,17 @@ class MockBackend: RCBackend { var invokedGetSubscriberDataParametersList = [(appUserID: String?, completion: RCBackendPurchaserInfoResponseHandler?)]() + var stubbedGetSubscriberDataPurchaserInfo: Purchases.PurchaserInfo? = nil + var stubbedGetSubscriberDataError: Error? = nil + + override func getSubscriberData(withAppUserID appUserID: String, completion: @escaping RCBackendPurchaserInfoResponseHandler) { invokedGetSubscriberData = true invokedGetSubscriberDataCount += 1 invokedGetSubscriberDataParameters = (appUserID, completion) invokedGetSubscriberDataParametersList.append((appUserID, completion)) + completion(stubbedGetSubscriberDataPurchaserInfo, stubbedGetSubscriberDataError) } var invokedGetIntroEligibility = false diff --git a/PurchasesTests/Mocks/MockOperationDispatcher.swift b/PurchasesTests/Mocks/MockOperationDispatcher.swift index 686e4d50e5..3aea7bee25 100644 --- a/PurchasesTests/Mocks/MockOperationDispatcher.swift +++ b/PurchasesTests/Mocks/MockOperationDispatcher.swift @@ -8,11 +8,38 @@ import Foundation @testable import PurchasesCoreSwift class MockOperationDispatcher: OperationDispatcher { + + var invokedDispatchOnMainThread = false + var invokedDispatchOnMainThreadCount = 0 + var shouldInvokeDispatchOnMainThreadBlock = true + var forwardToOriginalDispatchOnMainThread = false + override func dispatchOnMainThread(_ block: @escaping () -> Void) { - block() + invokedDispatchOnMainThread = true + invokedDispatchOnMainThreadCount += 1 + if forwardToOriginalDispatchOnMainThread { + super.dispatchOnMainThread(block) + return + } + if shouldInvokeDispatchOnMainThreadBlock { + block() + } } + var invokedDispatchOnWorkerThread = false + var invokedDispatchOnWorkerThreadCount = 0 + var shouldInvokeDispatchOnWorkerThreadBlock = true + var forwardToOriginalDispatchOnWorkerThread = false + override func dispatchOnWorkerThread(_ block: @escaping () -> Void) { - block() + invokedDispatchOnWorkerThread = true + invokedDispatchOnWorkerThreadCount += 1 + if forwardToOriginalDispatchOnWorkerThread { + super.dispatchOnWorkerThread(block) + return + } + if shouldInvokeDispatchOnWorkerThreadBlock { + block() + } } } diff --git a/PurchasesTests/Mocks/MockReceiptParser.swift b/PurchasesTests/Mocks/MockReceiptParser.swift new file mode 100644 index 0000000000..901b922121 --- /dev/null +++ b/PurchasesTests/Mocks/MockReceiptParser.swift @@ -0,0 +1,30 @@ +// +// Created by Andrés Boedo on 8/27/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import PurchasesCoreSwift + +class MockReceiptParser: ReceiptParser { + + init() { + super.init(objectIdentifierBuilder: ASN1ObjectIdentifierBuilder(), + containerBuilder: ASN1ContainerBuilder(), + receiptBuilder: AppleReceiptBuilder()) + } + + var invokedReceiptHasTransactions = false + var invokedReceiptHasTransactionsCount = 0 + var invokedReceiptHasTransactionsParameters: (receiptData: Data, Void)? + var invokedReceiptHasTransactionsParametersList = [(receiptData: Data, Void)]() + var stubbedReceiptHasTransactionsResult: Bool! = false + + override func receiptHasTransactions(receiptData: Data) -> Bool { + invokedReceiptHasTransactions = true + invokedReceiptHasTransactionsCount += 1 + invokedReceiptHasTransactionsParameters = (receiptData, ()) + invokedReceiptHasTransactionsParametersList.append((receiptData, ())) + return stubbedReceiptHasTransactionsResult + } +} diff --git a/PurchasesTests/Purchasing/PurchasesTests.swift b/PurchasesTests/Purchasing/PurchasesTests.swift index b8fbac75e9..8d22d93754 100644 --- a/PurchasesTests/Purchasing/PurchasesTests.swift +++ b/PurchasesTests/Purchasing/PurchasesTests.swift @@ -16,6 +16,7 @@ class PurchasesTests: XCTestCase { systemInfo = MockSystemInfo(platformFlavor: nil, platformFlavorVersion: nil, finishTransactions: true) mockOperationDispatcher = MockOperationDispatcher() mockIntroEligibilityCalculator = MockIntroEligibilityCalculator() + mockReceiptParser = MockReceiptParser() } override func tearDown() { @@ -186,7 +187,8 @@ class PurchasesTests: XCTestCase { var systemInfo: MockSystemInfo! var mockOperationDispatcher: MockOperationDispatcher! var mockIntroEligibilityCalculator: MockIntroEligibilityCalculator! - + var mockReceiptParser: MockReceiptParser! + let purchasesDelegate = MockPurchasesDelegate() var purchases: Purchases! @@ -194,53 +196,23 @@ class PurchasesTests: XCTestCase { func setupPurchases(automaticCollection: Bool = false) { Purchases.automaticAppleSearchAdsAttributionCollection = automaticCollection self.identityManager.mockIsAnonymous = false - - purchases = Purchases(appUserID: identityManager.currentAppUserID, - requestFetcher: requestFetcher, - receiptFetcher: receiptFetcher, - attributionFetcher: attributionFetcher, - backend: backend, - storeKitWrapper: storeKitWrapper, - notificationCenter: notificationCenter, - userDefaults: userDefaults, - systemInfo: systemInfo, - offeringsFactory: offeringsFactory, - deviceCache: deviceCache, - identityManager: identityManager, - subscriberAttributesManager: subscriberAttributesManager, - operationDispatcher: mockOperationDispatcher, - introEligibilityCalculator: mockIntroEligibilityCalculator) - purchases!.delegate = purchasesDelegate - Purchases.setDefaultInstance(purchases!) + + initializePurchasesInstance(appUserId: identityManager.currentAppUserID) } func setupAnonPurchases() { Purchases.automaticAppleSearchAdsAttributionCollection = false self.identityManager.mockIsAnonymous = true - - purchases = Purchases(appUserID: nil, - requestFetcher: requestFetcher, - receiptFetcher: receiptFetcher, - attributionFetcher: attributionFetcher, - backend: backend, - storeKitWrapper: storeKitWrapper, - notificationCenter: notificationCenter, - userDefaults: userDefaults, - systemInfo: systemInfo, - offeringsFactory: offeringsFactory, - deviceCache: deviceCache, - identityManager: identityManager, - subscriberAttributesManager: subscriberAttributesManager, - operationDispatcher: mockOperationDispatcher, - introEligibilityCalculator: mockIntroEligibilityCalculator) - - purchases!.delegate = purchasesDelegate + initializePurchasesInstance(appUserId: nil) } func setupPurchasesObserverModeOn() { - let systemInfo = RCSystemInfo(platformFlavor: nil, platformFlavorVersion: nil, finishTransactions: false) + systemInfo = MockSystemInfo(platformFlavor: nil, platformFlavorVersion: nil, finishTransactions: false) + initializePurchasesInstance(appUserId: nil) + } - purchases = Purchases(appUserID: nil, + private func initializePurchasesInstance(appUserId: String?) { + purchases = Purchases(appUserID: appUserId, requestFetcher: requestFetcher, receiptFetcher: receiptFetcher, attributionFetcher: attributionFetcher, @@ -254,7 +226,8 @@ class PurchasesTests: XCTestCase { identityManager: identityManager, subscriberAttributesManager: subscriberAttributesManager, operationDispatcher: mockOperationDispatcher, - introEligibilityCalculator: mockIntroEligibilityCalculator) + introEligibilityCalculator: mockIntroEligibilityCalculator, + receiptParser: mockReceiptParser) purchases!.delegate = purchasesDelegate Purchases.setDefaultInstance(purchases!) @@ -929,6 +902,68 @@ class PurchasesTests: XCTestCase { expect(self.backend.postReceiptDataCalled).to(beTrue()) } + func testRestoringPurchasesDoesntPostIfReceiptEmptyAndPurchaserInfoLoaded() { + let info = Purchases.PurchaserInfo(data: [ + "subscriber": [ + "subscriptions": [:], + "other_purchases": [:], + "original_application_version": "1.0", + "original_purchase_date": "2018-10-26T23:17:53Z" + ]]); + + let jsonObject = info!.jsonObject() + + let object = try! JSONSerialization.data(withJSONObject: jsonObject, options: []); + self.deviceCache.cachedPurchaserInfo[identityManager.currentAppUserID] = object + + mockReceiptParser.stubbedReceiptHasTransactionsResult = false + + setupPurchases() + purchases!.restoreTransactions() + + expect(self.backend.postReceiptDataCalled) == false + } + + func testRestoringPurchasesPostsIfReceiptEmptyAndPurchaserInfoNotLoaded() { + mockReceiptParser.stubbedReceiptHasTransactionsResult = false + + setupPurchases() + purchases!.restoreTransactions() + + expect(self.backend.postReceiptDataCalled) == true + } + + func testRestoringPurchasesPostsIfReceiptHasTransactionsAndPurchaserInfoLoaded() { + let info = Purchases.PurchaserInfo(data: [ + "subscriber": [ + "subscriptions": [:], + "other_purchases": [:], + "original_application_version": "1.0", + "original_purchase_date": "2018-10-26T23:17:53Z" + ]]); + + let jsonObject = info!.jsonObject() + + let object = try! JSONSerialization.data(withJSONObject: jsonObject, options: []); + self.deviceCache.cachedPurchaserInfo[identityManager.currentAppUserID] = object + + mockReceiptParser.stubbedReceiptHasTransactionsResult = true + + setupPurchases() + purchases!.restoreTransactions() + + expect(self.backend.postReceiptDataCalled) == true + } + + func testRestoringPurchasesPostsIfReceiptHasTransactionsAndPurchaserInfoNotLoaded() { + mockReceiptParser.stubbedReceiptHasTransactionsResult = true + + setupPurchases() + purchases!.restoreTransactions() + + expect(self.backend.postReceiptDataCalled) == true + } + func testRestoringPurchasesAlwaysRefreshesAndPostsTheReceipt() { setupPurchases() self.receiptFetcher.shouldReturnReceipt = true diff --git a/PurchasesTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift b/PurchasesTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift index dd8b4740e2..8b5dc1e4de 100644 --- a/PurchasesTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift +++ b/PurchasesTests/SubscriberAttributes/BackendSubscriberAttributesTests.swift @@ -230,6 +230,35 @@ class BackendSubscriberAttributesTests: XCTestCase { expect(self.mockHTTPClient.invokedPerformRequestCount) == 0 } + func testPostSubscriberAttributesCallsCompletionWithErrorInNotFoundCase() { + var completionCallCount = 0 + mockHTTPClient.shouldInvokeCompletion = true + mockHTTPClient.stubbedCompletionStatusCode = 404 + mockHTTPClient.stubbedCompletionError = nil + + var receivedError: Error? = nil + backend.postSubscriberAttributes([ + subscriberAttribute1.key: subscriberAttribute1, + subscriberAttribute2.key: subscriberAttribute2 + ], + appUserID: appUserID, + completion: { (error: Error!) in + completionCallCount += 1 + receivedError = error + }) + + expect(self.mockHTTPClient.invokedPerformRequestCount) == 1 + expect(completionCallCount).toEventually(equal(1)) + expect(receivedError).toNot(beNil()) + expect(receivedError).to(beAKindOf(Error.self)) + + let receivedNSError = receivedError! as NSError + expect(receivedNSError.code) == Purchases.ErrorCode.unknownBackendError.rawValue + expect(receivedNSError.successfullySynced()) == false + expect(receivedNSError.userInfo[RCSuccessfullySyncedKey]).toNot(beNil()) + expect((receivedNSError.userInfo[RCSuccessfullySyncedKey] as! NSNumber).boolValue) == false + } + // MARK: PostReceipt with subscriberAttributes func testPostReceiptWithSubscriberAttributesSendsThemCorrectly() { diff --git a/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift b/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift index 03f46c9eb6..3bf3761774 100644 --- a/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift +++ b/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift @@ -24,10 +24,11 @@ class PurchasesSubscriberAttributesTests: XCTestCase { var subscriberAttributeHeight: RCSubscriberAttribute! var subscriberAttributeWeight: RCSubscriberAttribute! var mockAttributes: [String: RCSubscriberAttribute]! - let systemInfo: RCSystemInfo = RCSystemInfo(platformFlavor: nil, - platformFlavorVersion: nil, - finishTransactions: true) - + let systemInfo: RCSystemInfo = MockSystemInfo(platformFlavor: nil, + platformFlavorVersion: nil, + finishTransactions: true) + var mockReceiptParser: MockReceiptParser! + var mockOperationDispatcher: MockOperationDispatcher! var mockIntroEligibilityCalculator: MockIntroEligibilityCalculator! @@ -47,6 +48,7 @@ class PurchasesSubscriberAttributesTests: XCTestCase { ] self.mockOperationDispatcher = MockOperationDispatcher() self.mockIntroEligibilityCalculator = MockIntroEligibilityCalculator() + self.mockReceiptParser = MockReceiptParser() } override func tearDown() { @@ -73,7 +75,8 @@ class PurchasesSubscriberAttributesTests: XCTestCase { identityManager: mockIdentityManager, subscriberAttributesManager: mockSubscriberAttributesManager, operationDispatcher: mockOperationDispatcher, - introEligibilityCalculator: mockIntroEligibilityCalculator) + introEligibilityCalculator: mockIntroEligibilityCalculator, + receiptParser: mockReceiptParser) purchases!.delegate = purchasesDelegate Purchases.setDefaultInstance(purchases!) } @@ -123,6 +126,30 @@ class PurchasesSubscriberAttributesTests: XCTestCase { expect(self.mockSubscriberAttributesManager.invokedSyncAttributesForAllUsersCount) == 2 } + func testSubscriberAttributesSyncIsPerformedAfterPurchaserInfoSync() { + mockBackend.stubbedGetSubscriberDataPurchaserInfo = Purchases.PurchaserInfo(data: [ + "subscriber": [ + "subscriptions": [:], + "other_purchases": [:], + "original_application_version": "1.0", + "original_purchase_date": "2018-10-26T23:17:53Z" + ] + ]) + + setupPurchases() + + expect(self.mockOperationDispatcher.invokedDispatchOnWorkerThreadCount) == 1 + expect(self.mockBackend.invokedGetSubscriberDataCount) == 1 + expect(self.mockSubscriberAttributesManager.invokedSyncAttributesForAllUsersCount) == 0 + expect(self.mockDeviceCache.cachedPurchaserInfo.count) == 1 + + self.mockNotificationCenter.fireNotifications() + + expect(self.mockOperationDispatcher.invokedDispatchOnWorkerThreadCount) == 3 + expect(self.mockSubscriberAttributesManager.invokedSyncAttributesForAllUsersCount) == 2 + expect(self.mockDeviceCache.cachedPurchaserInfo.count) == 1 + } + // Mark: Set attributes func testSetAttributesMakesRightCalls() { diff --git a/bin/release_version.sh b/bin/release_version.sh index 0ad1e1d199..d09b8b86f5 100755 --- a/bin/release_version.sh +++ b/bin/release_version.sh @@ -45,7 +45,8 @@ pod trunk push Purchases.podspec echo "Preparing Carthage release" echo "building..." -carthage build --archive +carthage build --no-skip-current +carthage archive Purchases echo "creating uploads folder if needed" mkdir $CARTHAGE_UPLOADS_PATH diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3d7fe00db4..658479a6f4 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -55,8 +55,6 @@ platform :ios do fail "please add a CHANGELOG.latest.md file before calling this lane" end - xcframeworks_zip_path = export_xcframeworks(output_directory: "builds/xcframeworks") - set_github_release( repository_name: "revenuecat/purchases-ios", api_token: ENV["GITHUB_TOKEN"], @@ -64,7 +62,7 @@ platform :ios do tag_name: "#{release_version}", description: changelog, commitish: "master", - upload_assets: ["CarthageUploads/Purchases.framework.zip", xcframeworks_zip_path], + upload_assets: ["CarthageUploads/Purchases.framework.zip"], is_draft: false ) end @@ -83,35 +81,6 @@ platform :ios do Spaceship::Tunes::SandboxTester.create!(email: email, password: password) end - desc "Export XCFrameworks" - lane :export_xcframeworks do |options| - output_directory = options[:output_directory] - fail ArgumentError, "missing output directory" unless output_directory - - platforms = [ - 'iOS', - 'macOS', - 'tvOS', - 'watchOS' - ] - create_xcframework( - destinations: platforms, - scheme: 'PurchasesCoreSwift', - xcframework_output_directory: output_directory - ) - - create_xcframework( - destinations: platforms, - scheme: 'Purchases', - xcframework_output_directory: output_directory - ) - - xcframeworks_zip_path = output_directory + ".zip" - zip(path: output_directory, - output_path: xcframeworks_zip_path) - return xcframeworks_zip_path - end - end def increment_build_number(previous_version_number, new_version_number, file_path) diff --git a/fastlane/README.md b/fastlane/README.md index c521483b85..a8dd09fd6a 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -41,11 +41,6 @@ Make github release fastlane ios create_sandbox_account ``` Create sandbox account -### ios export_xcframeworks -``` -fastlane ios export_xcframeworks -``` -Export XCFrameworks ----