From 5a5faa12981c3c395450184ad80faf11878c7e7c Mon Sep 17 00:00:00 2001 From: Cesar de la Vega Date: Tue, 11 Aug 2020 11:09:26 -0700 Subject: [PATCH] Preparing for next version (#312) * Preparing for next version * Update RCSystemInfo.m * Update .jazzy.yaml * Update .jazzy.yaml * back to 3.6.0-SNAPSHOT added TPInAppReceipt pod, added logic to get intro eligibility from receipt finished main logic for intro eligibility small fixes to retention in closures replaced pod dependency with embedded classes added TPInAppReceipt license clean up on intro eligibility calculator updated the logic to get products info so that only one SKProductsRequest gets issued added using the cache in productsManager cleanup: used DI for the IntroEligibilityCalculator fixed TPInAppReceipt compatibility with watchOS added syncing with a queue + support for multiple concurrent requests to product manager remove api key specified os version availability for intro calculation in detail ensured that data can't be nil before sending it to parser also covered the case where receipt data is not null, but is empty added missing import remove pod from podspec added files for local testing WIP interpreting bytes from receipt added parsing of data length, added methods to make parsing individual bits and bit ranges easier added more generalized code to get asn1 containers cleanup: moved methods into UInt8 extensinos more cleanup: moved method into ASN1Container init made constructor recursive, fixed issues with indexes more iteration fixes small cleanup started unpacking InAppReceiptAttributes more decoding of attributes more decoding of attributes decoded missing attributes added algorithm for decoding object identifier started cleanup: moved stuff to a separate class cleanup, separated objectIdentifierParser WIP extracting stuff into classes added methods for easier print debugging more cleanup added more (undocummented) fields to in app purchase moved to using our own solution instead of TPInAppReceipt for intro eligibility removed TPInAppReceipt cleanup, renames, removed unused files separated inAppPurchase into its own class split extraction logic into several factories moved to using a single date formatter method naming clenaup separated ASN1ObjectIdentifier from factory small update to use DI for the date formatter more cleanup: unified duplicated logic to convert from bytes to different value types renamed file from previous commit moved more responsibility to the apple receipt factory, simplified the AppleReceipt class moved logic for querying the apple receipt into the apple receipt class moved more responsibility to the inAppPurchaseFactory and away from InAppPurchase removed redundant class LocalReceiptParser removed all force unwraps and replaced assumptions with more robust error handling and throwing custom exceptions split code into builders, basic types and utilities renamed factory -> builder renamed factory -> builder renamed Utilities -> Data Converters removed unused extractableValueType protocols added UInt8 extensions tests, simplified guard conditions added tests for ArraySlice+Extensions added tests for iOS3601 date formatter renamed ASN1Type -> ASN1Identifier. Added first tests for ASN1ContainerBuilder added more tests for asn1ContainerBuiler added tests for short length asn1 containers fixed rebase issues cleanup added tests for container length, added description to ASN1 errors added more details to error cases, added tests for container length removed unused property added more tests for length and internal payload made internal containers `let` since it's never modified outside of construction. added tests for asn1ContainerBuilder's building of internal containers - removed receiptAsData.txt, renamed sample receipt, added tests that compare directly against the sample receipt and serve as integration tests for local receipt parsing. small cleanup changed type of ASN1Length.value from UInt -> Int inApp -> inAppPurchase purchasedIntroOfferProductIdentifiers -> purchasedIntroOfferOrFreeTrialProductIdentifiers clean up naming oid -> objectIdentifier more naming cleanup WIP adding container factory to make tests easier added more factory methods added more logic to be able to compose receipts for testing purposes. added tests for minimal attributes in the receipt added more tests for minimal attributes added test for expires date cleanup in container factory added base code to be able to compose in app purchase containers for testing purposes added tests for minimal attributes in in-app purchases added tests for optional attributes adds tests to ensure that in-app purchase build fails if attributes are missing, fixed tests not running for in-app purchase builder. fixed bug in tests added ASN.1 object identifier encoding to be able test decoding added tests for all known object identifiers added checks for empty and invalid object identifiers cleanup added tests to check that in app purchases are correctly added to the receipt simplified ASN1ObjectIdentifierBuilder logic fixed rebase issues added some tests for ReceiptParser renamed the methods in container factory removing the redundant build prefix added remaining tests for ReceiptParser moved containerFactory into TestHelpers cleanup in IntroEligibilityCalculator, added class for tests replaced classes with mocks added more tests updated intro eligibility calculator swift name so name lookup works correctly in tests updated Swift name for other internal swift classes so we won't run into lookup issues with them added test to check that only one SKProducts call is issued added tests to check that eligibility is calculated correctly removed redundant file SKProduct+TestExtensions, replaced with mockSKProduct extracted mockProductsRequest into separate file for reuse added more tests added final tests for productsManager fixed rebase issue added link to docs for object identifiers added link to apple docs for apple receipt fields cleanup: extracted method to build internal containers added docs and extracted method for variable length quantity decoder added a couple of comments --- .gitignore | 4 +- ...e - Local Environment (Xcode 12+).xcscheme | 25 +- .../xcschemes/WatchExample.xcscheme | 25 +- .../SwiftExample/AppDelegate.swift | 4 + Purchases.xcodeproj/project.pbxproj | 245 +++++++++++- .../RCPurchases+Protected.h | 6 +- Purchases/Public/RCPurchases.m | 73 ++-- .../SwiftInterfaces/RCOperationDispatcher.h | 0 .../SwiftInterfaces/RCTransactionsFactory.h | 0 .../IntroEligibilityCalculator.swift | 94 +++++ .../BasicTypes/ASN1Container.swift | 62 +++ .../BasicTypes/ASN1ObjectIdentifier.swift | 16 + .../BasicTypes/AppleReceipt.swift | 63 +++ .../BasicTypes/InAppPurchase.swift | 69 ++++ .../Builders/ASN1ContainerBuilder.swift | 96 +++++ .../ASN1ObjectIdentifierBuilder.swift | 48 +++ .../Builders/AppleReceiptBuilder.swift | 97 +++++ .../Builders/InAppPurchaseBuilder.swift | 103 +++++ .../ArraySlice+Extensions.swift | 38 ++ .../DataConverters/ISO3601DateFormatter.swift | 23 ++ .../DataConverters/UInt8+Extensions.swift | 45 +++ .../LocalReceiptParsing/ReceiptParser.swift | 58 +++ .../ReceiptParsingError.swift | 37 ++ .../SwiftSources/Misc/DateExtensions.swift | 22 ++ .../Purchasing/ProductsManager.swift | 99 +++++ .../Purchasing/ProductsRequestFactory.swift | 12 + .../LocalReceiptParser.swift | 30 -- .../LocalReceiptParserTests.swift | 17 - .../Mocks/MockASN1ContainerBuilder.swift | 28 ++ .../Mocks/MockAppleReceiptBuilder.swift | 28 ++ .../Mocks/MockInAppPurchaseBuilder.swift | 28 ++ .../MockIntroEligibilityCalculator.swift | 36 ++ .../Mocks/MockProductsManager.swift | 27 ++ .../Mocks/MockProductsRequest.swift | 48 +++ .../Mocks/MockProductsRequestFactory.swift | 25 ++ PurchasesTests/Mocks/MockReceiptParser.swift | 35 ++ PurchasesTests/Mocks/MockSKProduct.swift | 3 +- .../Purchasing/PurchasesTests.swift | 11 +- .../StoreKitRequestFetcherTests.swift | 45 +-- .../receipts/base64encodedreceiptsample1.txt | 1 + .../receipts/verifyReceiptSample1.txt | 369 ++++++++++++++++++ .../PurchasesSubscriberAttributesTests.swift | 7 +- .../IntroEligibilityCalculatorTests.swift | 173 ++++++++ .../Builders/ASN1ContainerBuilderTests.swift | 207 ++++++++++ .../ASN1ObjectIdentifierBuilderTests.swift | 49 +++ .../Builders/AppleReceiptBuilderTests.swift | 221 +++++++++++ .../Builders/InAppPurchaseBuilderTests.swift | 316 +++++++++++++++ .../ArraySlice+ExtensionsTests.swift | 17 + .../ISO3601DateFormatterTests.swift | 30 ++ .../UInt8+ExtensionsTests.swift | 38 ++ .../ReceiptParserTests.swift | 131 +++++++ ...ReceiptParsing+TestsWithRealReceipts.swift | 193 +++++++++ .../Purchasing/ProductsManagerTests.swift | 93 +++++ .../ASN1ObjectIdentifierEncoder.swift | 65 +++ .../TestHelpers/ContainerFactory.swift | 177 +++++++++ 55 files changed, 3665 insertions(+), 147 deletions(-) create mode 100644 Purchases/SwiftInterfaces/RCOperationDispatcher.h create mode 100644 Purchases/SwiftInterfaces/RCTransactionsFactory.h create mode 100644 Purchases/SwiftSources/IntroEligibilityCalculator.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1Container.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+Extensions.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatter.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/ReceiptParser.swift create mode 100644 Purchases/SwiftSources/LocalReceiptParsing/ReceiptParsingError.swift create mode 100644 Purchases/SwiftSources/Misc/DateExtensions.swift create mode 100644 Purchases/SwiftSources/Purchasing/ProductsManager.swift create mode 100644 Purchases/SwiftSources/Purchasing/ProductsRequestFactory.swift create mode 100644 PurchasesTests/Mocks/MockASN1ContainerBuilder.swift create mode 100644 PurchasesTests/Mocks/MockAppleReceiptBuilder.swift create mode 100644 PurchasesTests/Mocks/MockInAppPurchaseBuilder.swift create mode 100644 PurchasesTests/Mocks/MockIntroEligibilityCalculator.swift create mode 100644 PurchasesTests/Mocks/MockProductsManager.swift create mode 100644 PurchasesTests/Mocks/MockProductsRequest.swift create mode 100644 PurchasesTests/Mocks/MockProductsRequestFactory.swift create mode 100644 PurchasesTests/Mocks/MockReceiptParser.swift create mode 100644 PurchasesTests/Resources/receipts/base64encodedreceiptsample1.txt create mode 100644 PurchasesTests/Resources/receipts/verifyReceiptSample1.txt create mode 100644 PurchasesTests/SwiftSources/IntroEligibilityCalculatorTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilderTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilderTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilderTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilderTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+ExtensionsTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatterTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+ExtensionsTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/ReceiptParserTests.swift create mode 100644 PurchasesTests/SwiftSources/LocalReceiptParsing/TestsAgainstRealReceipts/ReceiptParsing+TestsWithRealReceipts.swift create mode 100644 PurchasesTests/SwiftSources/Purchasing/ProductsManagerTests.swift create mode 100644 PurchasesTests/TestHelpers/ASN1ObjectIdentifierEncoder.swift create mode 100644 PurchasesTests/TestHelpers/ContainerFactory.swift diff --git a/.gitignore b/.gitignore index 1e5247291f..36940877d6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,5 @@ CarthageUploads fastlane/report.xml fastlane/test_output/ CHANGELOG.latest.md - -builds/ \ No newline at end of file +builds/ +Pods/ diff --git a/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample - Local Environment (Xcode 12+).xcscheme b/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample - Local Environment (Xcode 12+).xcscheme index 0251046a84..11cdbf9e43 100644 --- a/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample - Local Environment (Xcode 12+).xcscheme +++ b/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample - Local Environment (Xcode 12+).xcscheme @@ -55,8 +55,10 @@ debugServiceExtension = "internal" allowLocationSimulation = "YES" notificationPayloadFile = "WatchExample Extension/PushNotificationPayload.apns"> - + - + @@ -75,8 +77,10 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> - + - + + + + + diff --git a/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample.xcscheme b/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample.xcscheme index 47e14a8d6b..dc1e022c63 100644 --- a/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample.xcscheme +++ b/Examples/SwiftExample/SwiftExample.xcodeproj/xcshareddata/xcschemes/WatchExample.xcscheme @@ -55,8 +55,10 @@ debugServiceExtension = "internal" allowLocationSimulation = "YES" notificationPayloadFile = "WatchExample Extension/PushNotificationPayload.apns"> - + - + - + - + + + + + diff --git a/Examples/SwiftExample/SwiftExample/AppDelegate.swift b/Examples/SwiftExample/SwiftExample/AppDelegate.swift index fee85964d1..eb6dfcdd51 100644 --- a/Examples/SwiftExample/SwiftExample/AppDelegate.swift +++ b/Examples/SwiftExample/SwiftExample/AppDelegate.swift @@ -27,6 +27,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // we're requesting push notifications on app start only to showcase how to set the push token in RevenueCat requestPushNotificationsPermissions() + + Purchases.shared.checkTrialOrIntroductoryPriceEligibility(["com.revenuecat.monthly_4.99.1_week_intro"]) { introEligibilityDict in + print(introEligibilityDict) + } return true } diff --git a/Purchases.xcodeproj/project.pbxproj b/Purchases.xcodeproj/project.pbxproj index 3c0c22972b..e23f5b4ea7 100644 --- a/Purchases.xcodeproj/project.pbxproj +++ b/Purchases.xcodeproj/project.pbxproj @@ -8,19 +8,25 @@ /* Begin PBXBuildFile section */ 2D4C18A924F47E5000F268CD /* Purchases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C18A824F47E4400F268CD /* Purchases.swift */; }; + 2D0B060324D0C8F8007BB162 /* base64encodedreceiptsample1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2DDE559B24C8B5E300DCB087 /* base64encodedreceiptsample1.txt */; }; + 2D0B060524D0C8FF007BB162 /* verifyReceiptSample1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2DDE559A24C8B5E300DCB087 /* verifyReceiptSample1.txt */; }; + 2D33A66124D899AD00893BD4 /* UInt8+ExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3569FAC5F05C94DB5FE3B /* UInt8+ExtensionsTests.swift */; }; + 2D33A66224D8B67400893BD4 /* ASN1ContainerBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35CB72101C1CA642C101C /* ASN1ContainerBuilderTests.swift */; }; 2D5033242406E4E8009CAE61 /* RCSubscriberAttribute.h in Headers */ = {isa = PBXBuildFile; fileRef = 2D5033212406E4E8009CAE61 /* RCSubscriberAttribute.h */; settings = {ATTRIBUTES = (Private, ); }; }; 2D5033252406E4E8009CAE61 /* RCSubscriberAttribute.m in Sources */ = {isa = PBXBuildFile; fileRef = 2D5033222406E4E8009CAE61 /* RCSubscriberAttribute.m */; }; 2D5033262406E4E8009CAE61 /* RCSpecialSubscriberAttributes.h in Headers */ = {isa = PBXBuildFile; fileRef = 2D5033232406E4E8009CAE61 /* RCSpecialSubscriberAttributes.h */; settings = {ATTRIBUTES = (Private, ); }; }; 2D50332A2406EA61009CAE61 /* RCSubscriberAttributesManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 2D5033272406EA61009CAE61 /* RCSubscriberAttributesManager.h */; settings = {ATTRIBUTES = (Private, ); }; }; 2D50332B2406EA61009CAE61 /* RCSubscriberAttributesManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 2D5033282406EA61009CAE61 /* RCSubscriberAttributesManager.m */; }; + 2D59D15B24DAEA1D0057BB58 /* RCTransactionsFactory.h in Headers */ = {isa = PBXBuildFile; fileRef = 2D59D15A24DAEA1D0057BB58 /* RCTransactionsFactory.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 2D5BB46B24C8E8ED00E27537 /* ReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5BB46A24C8E8ED00E27537 /* ReceiptParser.swift */; }; + 2D78007424DB5F6900B2FB5A /* RCIntroEligibilityCalculator.h in Headers */ = {isa = PBXBuildFile; fileRef = 2D78007324DB5F6900B2FB5A /* RCIntroEligibilityCalculator.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 2D8D134824DE0AE900B7EAE9 /* InAppPurchaseBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E357B400D3028BC970DAC1 /* InAppPurchaseBuilderTests.swift */; }; 2D8DB34B24072AAE00BE3D31 /* SubscriberAttributeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8DB34A24072AAE00BE3D31 /* SubscriberAttributeTests.swift */; }; 2DC5621F24EC63430031F69B /* PurchasesCoreSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DC5621624EC63420031F69B /* PurchasesCoreSwift.framework */; }; 2DC5622624EC63430031F69B /* PurchasesCoreSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 2DC5621824EC63430031F69B /* PurchasesCoreSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; 2DC5622E24EC636C0031F69B /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E355CBB3F3A31A32687B14 /* Transaction.swift */; }; 2DC5623024EC63730031F69B /* OperationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DDA3E4624DB0B5400EDFE5B /* OperationDispatcher.swift */; }; - 2DC5623124EC63730031F69B /* LocalReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1A28CC24AA6F81006BE931 /* LocalReceiptParser.swift */; }; 2DC5623224EC63730031F69B /* TransactionsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3597020F24BF6A710010506E /* TransactionsFactory.swift */; }; - 2DC5623624EC68090031F69B /* LocalReceiptParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD02D5A24AD129A00419CD9 /* LocalReceiptParserTests.swift */; }; 2DC5623724EC68090031F69B /* TransactionsFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */; }; 2DC5623824EC7DF70031F69B /* RCTransaction.h in Headers */ = {isa = PBXBuildFile; fileRef = 2D604CA224E5BF37004821DC /* RCTransaction.h */; settings = {ATTRIBUTES = (Public, ); }; }; 2DD02D5724AD0B0500419CD9 /* RCIntroEligibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD02D5624AD0B0500419CD9 /* RCIntroEligibilityTests.swift */; }; @@ -60,13 +66,17 @@ 35DFC68620B87646004584CC /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 350FBDDC1F7EA3570065833D /* Nimble.framework */; }; 35DFC68720B87646004584CC /* OHHTTPStubs.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 350FBDD71F7DF17F0065833D /* OHHTTPStubs.framework */; }; 37E3500156786798CB166571 /* RCPurchases+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E352DAD631B3A45C041148 /* RCPurchases+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 37E350064866FDD37B6E6BA9 /* MockAppleReceiptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C1554F296F7F1317747 /* MockAppleReceiptBuilder.swift */; }; 37E35010ECA71A4CE6E16307 /* RCDeviceCache+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35DCFDBDCD33B531395A6 /* RCDeviceCache+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35024A14FBDD9A5DF0B7C /* NSLocale+RCExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35548F15DE7CFFCE3AA8A /* NSLocale+RCExtensions.m */; }; 37E3503FD7AB94A85A397EF3 /* RCHTTPStatusCodes.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35331DBA29B3C17D0F507 /* RCHTTPStatusCodes.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E3506DF4FDFCE5CA75B057 /* RCProductInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35FDC209D3F4C38791BDE /* RCProductInfo.m */; }; + 37E350940C1C75D6DFFB3F94 /* ISO3601DateFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E352A24623FB706B93D9F1 /* ISO3601DateFormatterTests.swift */; }; + 37E35096F00109F827375249 /* MockProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C9439E087F63ECC4F59 /* MockProductsManager.swift */; }; 37E3509E9E39B1FFE93351B1 /* NSError+RCExtensions.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35D12020065BE7D6E473D /* NSError+RCExtensions.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E350AAD0BAE521A7F94846 /* RCAttributionFetcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35166EE0877FCCD3C0C2D /* RCAttributionFetcher.m */; }; 37E351505CB4764821451E27 /* ProductInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3583675928C01D92E3166 /* ProductInfoExtensions.swift */; }; + 37E3515B3BCEADD0C3B07470 /* MockASN1ContainerBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E351EB3689AF304E5B1031 /* MockASN1ContainerBuilder.swift */; }; 37E351A24D613F0DFE6AF05F /* MockBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35645928A0009F4C105A7 /* MockBackend.swift */; }; 37E351AB03EE37534CA10B59 /* MockInMemoryCachedOfferings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C5A65AAF701DED59800 /* MockInMemoryCachedOfferings.swift */; }; 37E351B84EBE06E2F370A835 /* RCISOPeriodFormatter.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35CC6289D46E544F5F006 /* RCISOPeriodFormatter.m */; }; @@ -74,7 +84,9 @@ 37E351F90612047842AFF1A6 /* ProductInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E350E57B0A393455A72B40 /* ProductInfoTests.swift */; }; 37E3524628A1D7568679FEE2 /* SusbcriberAttributesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3567E972B9B04FE079ABA /* SusbcriberAttributesManagerTests.swift */; }; 37E3524CB70618E6C5F3DB49 /* MockPurchasesDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35838A7FD36982EE14100 /* MockPurchasesDelegate.swift */; }; + 37E352554834F52537E5704F /* ReceiptParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E351D0EBC4698E1D3585A6 /* ReceiptParserTests.swift */; }; 37E352897F7CB3A122F9739F /* PurchasesSubscriberAttributesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3508E52201122137D4B4A /* PurchasesSubscriberAttributesTests.swift */; }; + 37E3528ABEF11A98469768CD /* ASN1ContainerBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E356F27BD672DBD9F34897 /* ASN1ContainerBuilder.swift */; }; 37E352BAA55C31B1E278CA8A /* RCLogUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E352229DFDDA008BB029C2 /* RCLogUtils.m */; }; 37E352E86A182E92130B823C /* RCIntroEligibility+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E359D128E667D950598853 /* RCIntroEligibility+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E352E9231D083B78E2E345 /* RCBackend.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35E282619939718FC7DBE /* RCBackend.m */; }; @@ -91,18 +103,25 @@ 37E354AFE06A9723230E47B8 /* RCInMemoryCachedObject+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35A6246A6969C7236B29B /* RCInMemoryCachedObject+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E354BE25CE61E55E4FD89C /* MockDeviceCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E354BEB8FDE39CAB7C4D69 /* MockDeviceCache.swift */; }; 37E354C46A8A3C2DC861C224 /* MockHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35292137BBF2810CE4F4B /* MockHTTPClient.swift */; }; + 37E354D42DC8EEA3D195CD2C /* MockProductsRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35F783903362B65FB7AF3 /* MockProductsRequestFactory.swift */; }; 37E354E0A9A371481540B2B0 /* MockAttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E351CD1EE6B897F434EA40 /* MockAttributionFetcher.swift */; }; 37E354F65C32F553F4697C0E /* RCBackend.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35A6E091B8CD9C967383C /* RCBackend.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E3554836D4DA9336B9FA70 /* MockProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3572040A16F10B957563A /* MockProductDiscount.swift */; }; 37E3555F3FB596CAB8F46868 /* BackendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E358BF58C99AC39073B96C /* BackendTests.swift */; }; 37E3556DD7FB5317A43086CC /* NSDate+RCExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E3521D9235DD78BFEE8AC4 /* NSDate+RCExtensions.m */; }; + 37E355AB4CA0C4BFA6154EE6 /* InAppPurchaseBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E356AC37809AE1FDAF1344 /* InAppPurchaseBuilder.swift */; }; + 37E355DF6C7226308E3B0BD0 /* ArraySlice+ExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E352E5B4549BB61C286B0D /* ArraySlice+ExtensionsTests.swift */; }; 37E355E7EB509047724841F4 /* BackendSubscriberAttributesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E357F69438004E1F443C03 /* BackendSubscriberAttributesTests.swift */; }; + 37E3560FE1D909EA2559FF5B /* ProductsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E351F0E21361EAEC078A0D /* ProductsManagerTests.swift */; }; 37E35616A3D5D204B8BB2503 /* RCStoreKitWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E359B77C1E588C9C009D2B /* RCStoreKitWrapper.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E356538C6A9A05887CAE12 /* NSError+RCExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E353AF2CAD3CEDE6D9B368 /* NSError+RCExtensionsTests.swift */; }; + 37E356644B9A1601CD40D7E0 /* ASN1ObjectIdentifierBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35FCCA2447C2A93C37431 /* ASN1ObjectIdentifierBuilder.swift */; }; + 37E3566468428D32D732D625 /* MockReceiptParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E354B13440508B46C9A530 /* MockReceiptParser.swift */; }; 37E3567EAB98594B4912E305 /* RCLogUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35EB9B60477A7AD2C2EB5 /* RCLogUtils.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E356E4DA9961A36D16F6E3 /* NSLocale+RCExtensions.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E3528315F4F007250F62B2 /* NSLocale+RCExtensions.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E357301A5D15C0E90C9BD3 /* RCProductInfoExtractor.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35BC489985F613020035D /* RCProductInfoExtractor.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E3575B0FFDD90D01861D81 /* RCPurchasesErrorUtils+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35CF71E13B58F4AC8998F /* RCPurchasesErrorUtils+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 37E35762A6E3324312EAB2BB /* ASN1Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E359DD0D1582B18AF80690 /* ASN1Container.swift */; }; 37E357E33F0E20D92EE6372E /* MockSKProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E357FBA3184BDE6E95DED4 /* MockSKProduct.swift */; }; 37E358250B07BF2DA06EA27B /* RCReceiptFetcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E3509E7B186A712097B8CF /* RCReceiptFetcher.m */; }; 37E358418A5A00E989CC1B30 /* RCReceiptFetcher.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E354E2E660D610B9CD4FA2 /* RCReceiptFetcher.h */; settings = {ATTRIBUTES = (Private, ); }; }; @@ -112,6 +131,8 @@ 37E35889F275514EE3125439 /* RCIdentityManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E351221B2DFCE79A333401 /* RCIdentityManager.m */; }; 37E358D3F3C7C0388FF5C2BD /* RCOfferingsFactory.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E352CB6BD9FE3D1F4946C5 /* RCOfferingsFactory.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E358DB4F4016AC297D6B00 /* RCOfferingsFactory.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35105C5C36A30D084954C /* RCOfferingsFactory.m */; }; + 37E358E535E53D385DF442FD /* AppleReceiptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3518863ECACCDEE2D6F7B /* AppleReceiptBuilder.swift */; }; + 37E358EC3900F272E94D40AC /* ArraySlice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35408E083ECE8BD4D670C /* ArraySlice+Extensions.swift */; }; 37E35931AE6E1CAD19525C2D /* RCIdentityManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E350B5671B69C9D9897A16 /* RCIdentityManager.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E3594AE079F6528C024B60 /* PurchasesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35DE5707E845DA3FF51BC /* PurchasesTests.swift */; }; 37E3595F797614307CBA329A /* StoreKitWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E352F86A0A8EB05BAD77C4 /* StoreKitWrapperTests.swift */; }; @@ -121,13 +142,17 @@ 37E359E815ECBE0B9074FA19 /* NSDate+RCExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3508EC20EEBAB4EAC4C82 /* NSDate+RCExtensionsTests.swift */; }; 37E35A299524507A480956D5 /* RCStoreKitWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35CE6628D07BD2C4A07C0 /* RCStoreKitWrapper.m */; }; 37E35A37D97E9E2B0CA09300 /* ISOPeriodFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35B9AC7A350CA2437049D /* ISOPeriodFormatterTests.swift */; }; + 37E35A685C0E1C7FD8B7E6D8 /* AppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35424E61D7A7EC1F457D4 /* AppleReceipt.swift */; }; 37E35A7ED3312F10B80FFE2B /* RCDeviceCache.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E356F4B9FCED933CD9C1CA /* RCDeviceCache.m */; }; + 37E35AB21E374E375F60E92A /* ASN1ObjectIdentifierEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C4A4B241A545D1D06BD /* ASN1ObjectIdentifierEncoder.swift */; }; 37E35AC4C81DB6A27C5AA1CE /* OfferingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E357C2D977BBB081216B5F /* OfferingsTests.swift */; }; 37E35AD0B0D9EF0CDA29DAC2 /* MockStoreKitWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E351AC0FF9607719F7A29A /* MockStoreKitWrapper.swift */; }; 37E35AD3BB8E99D2AA325825 /* DeviceCacheSubscriberAttributesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C4A795A0F056381A1B3 /* DeviceCacheSubscriberAttributesTests.swift */; }; 37E35AF213F2F79CB067FDC2 /* InMemoryCachedObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35E3250FBBB03D92E06EC /* InMemoryCachedObjectTests.swift */; }; 37E35B1F34D1624509012749 /* MockSubscriberAttributesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E351D48260D9DC8B1EE360 /* MockSubscriberAttributesManager.swift */; }; 37E35B4BDE7D7B1EFB3EE800 /* NSError+RCExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35088AB7C4F7A242D3ED5 /* NSError+RCExtensions.m */; }; + 37E35BB7BA59E7B94A47F6B2 /* ISO3601DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35F419D9D56D79369D690 /* ISO3601DateFormatter.swift */; }; + 37E35BD808CF7DE8960ACFBE /* ASN1ObjectIdentifierBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C8542B5C9C2749040D0 /* ASN1ObjectIdentifierBuilderTests.swift */; }; 37E35BDDC331C3A5FF72CFFF /* RCStoreKitRequestFetcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E351CDC94FE0A49A3DAAB3 /* RCStoreKitRequestFetcher.m */; }; 37E35BE05E2A8BB2BD310CB0 /* RCHTTPClient.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E356C39D29EB6C8EC2D6BD /* RCHTTPClient.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35C1B3C0170F15FC920F5 /* RCPackage+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35DF0786134609B68FA23 /* RCPackage+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; @@ -138,27 +163,36 @@ 37E35D195CD0599FDB381D04 /* HTTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E353CBE9CF2572A72A347F /* HTTPClientTests.swift */; }; 37E35D561B3F5AC49FD6C2CF /* RCHTTPClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35EDEA372C1D8E03118FF /* RCHTTPClient.m */; }; 37E35D757DE82291BFFCFB91 /* RCOfferings+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E354D3420651DECC1EA656 /* RCOfferings+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 37E35D841A9E1DD53D34231A /* ASN1ObjectIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35648821276317B23462E /* ASN1ObjectIdentifier.swift */; }; + 37E35DAB16813ABB5728A452 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35C7060D7E486F5958BED /* ProductsManager.swift */; }; 37E35DB1E991055497D18044 /* RCPromotionalOffer.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E357AE7E003DACF1635491 /* RCPromotionalOffer.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35DC1B42094073EB105AC /* RCSystemInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35A7A70B77C0526EA3A28 /* RCSystemInfo.m */; }; 37E35DD380900220C34BB222 /* MockTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3550C031F1FD95B366998 /* MockTransaction.swift */; }; 37E35DD66736A6669A746334 /* PurchaserInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E359D8F304C83184560135 /* PurchaserInfoTests.swift */; }; 37E35DD8BE40E352311AC2C1 /* RCPromotionalOffer.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35CFD3C14C7C6EECF921E /* RCPromotionalOffer.m */; }; + 37E35E0287F4583D0BF8A22B /* UInt8+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3572FE503DFCEC4CCAD54 /* UInt8+Extensions.swift */; }; 37E35E3A834982B03BC633BC /* StoreKitRequestFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35A6260C44FCBFF4CBB49 /* StoreKitRequestFetcherTests.swift */; }; 37E35E3CFED4426C0EE1302C /* RCOffering+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E357C8623F79A4F0BEE213 /* RCOffering+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35E41EAB29106B74B5FB3 /* NSDate+RCExtensions.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E355AE6CB674484555D1AC /* NSDate+RCExtensions.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 37E35E7A9AA2305A48463AFC /* InAppPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3519B59165AF5FFD5D943 /* InAppPurchase.swift */; }; + 37E35E835B0D043B78CCFBD3 /* MockProductsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35B08709090FBBFB16EBD /* MockProductsRequest.swift */; }; 37E35E9CEC36E93AF682D012 /* RCPromotionalOffer+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35D29AD7BE9B81DBF6907 /* RCPromotionalOffer+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35EA1E0F41A8A53236C4B /* RCSystemInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E353471F474D9092560033 /* RCSystemInfo.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35EA8EA9D49FD95778CC9 /* NSData+RCExtensions.h in Headers */ = {isa = PBXBuildFile; fileRef = 37E35097C6785A4A89DEBB18 /* NSData+RCExtensions.h */; settings = {ATTRIBUTES = (Private, ); }; }; 37E35EACCCC014F719E63D5F /* RCAttributionData.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E351D1C68CCD8A82FB105D /* RCAttributionData.m */; }; 37E35EB1B9FE3F739B8C0D71 /* RCDateProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 37E35F1729999CFCA36E877D /* RCDateProvider.m */; }; + 37E35EB2CCADA3EA3BCAEBA5 /* MockInAppPurchaseBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E355744D64075AA91342DE /* MockInAppPurchaseBuilder.swift */; }; 37E35EB7B35C86140B96C58B /* MockUserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3571B552018D47A6ED7C6 /* MockUserManager.swift */; }; 37E35EBDFC5CD3068E1792A3 /* MockNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35A0D4A561C51185F82EB /* MockNotificationCenter.swift */; }; + 37E35EDA5BAF7112FC2EAFC2 /* ContainerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35092F0E41512E0D610BA /* ContainerFactory.swift */; }; 37E35EDC57C486AC2D66B4B8 /* MockOfferingsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3555B4BE0A4F7222E7B00 /* MockOfferingsFactory.swift */; }; 37E35F0387D0ADE014186924 /* ProductInfoExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3548189DA008320B3FC98 /* ProductInfoExtractorTests.swift */; }; 37E35F150166B248756AAFEF /* NSData+RCExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E35ABEE9FD79CCA64E4F8B /* NSData+RCExtensionsTests.swift */; }; 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 */; }; + 37E35F89A7F467364EAF5640 /* AppleReceiptBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3553A641AADCAFE25AB3D /* AppleReceiptBuilderTests.swift */; }; + 37E35FCBAA6368002BF4A661 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E3567189CF6A746EE3CCC2 /* DateExtensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -200,7 +234,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2D1A28CC24AA6F81006BE931 /* LocalReceiptParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalReceiptParser.swift; sourceTree = ""; }; 2D4C18A824F47E4400F268CD /* Purchases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Purchases.swift; sourceTree = ""; }; 2D5033212406E4E8009CAE61 /* RCSubscriberAttribute.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RCSubscriberAttribute.h; path = Purchases/SubscriberAttributes/RCSubscriberAttribute.h; sourceTree = SOURCE_ROOT; }; 2D5033222406E4E8009CAE61 /* RCSubscriberAttribute.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RCSubscriberAttribute.m; path = Purchases/SubscriberAttributes/RCSubscriberAttribute.m; sourceTree = SOURCE_ROOT; }; @@ -208,21 +241,30 @@ 2D5033272406EA61009CAE61 /* RCSubscriberAttributesManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RCSubscriberAttributesManager.h; path = Purchases/SubscriberAttributes/RCSubscriberAttributesManager.h; sourceTree = SOURCE_ROOT; }; 2D5033282406EA61009CAE61 /* RCSubscriberAttributesManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RCSubscriberAttributesManager.m; path = Purchases/SubscriberAttributes/RCSubscriberAttributesManager.m; sourceTree = SOURCE_ROOT; }; 2D604CA224E5BF37004821DC /* RCTransaction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTransaction.h; sourceTree = ""; }; + 2D5BB46A24C8E8ED00E27537 /* ReceiptParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParser.swift; sourceTree = ""; }; + 2D78007324DB5F6900B2FB5A /* RCIntroEligibilityCalculator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCIntroEligibilityCalculator.h; sourceTree = ""; }; 2D8DB34A24072AAE00BE3D31 /* SubscriberAttributeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriberAttributeTests.swift; sourceTree = ""; }; 2DC5621624EC63420031F69B /* PurchasesCoreSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PurchasesCoreSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2DC5621824EC63430031F69B /* PurchasesCoreSwift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PurchasesCoreSwift.h; sourceTree = ""; }; 2DC5621924EC63430031F69B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2DC5621E24EC63430031F69B /* PurchasesCoreSwiftTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PurchasesCoreSwiftTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2DC5622524EC63430031F69B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2D8F622224D30F9D00F993AA /* ReceiptParsingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptParsingError.swift; sourceTree = ""; }; + 2D97458E24BDFCEF006245E9 /* IntroEligibilityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroEligibilityCalculator.swift; sourceTree = ""; }; + 2DA0068E24E2E515002C59D3 /* MockIntroEligibilityCalculator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockIntroEligibilityCalculator.swift; sourceTree = ""; }; + 2DA5A4B024DC8AD1000D2932 /* ReceiptParsing+TestsWithRealReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReceiptParsing+TestsWithRealReceipts.swift"; sourceTree = ""; }; + 2DD02D5424AD00ED00419CD9 /* RCPurchasesSwiftImport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCPurchasesSwiftImport.h; sourceTree = ""; }; 2DD02D5624AD0B0500419CD9 /* RCIntroEligibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RCIntroEligibilityTests.swift; sourceTree = ""; }; - 2DD02D5A24AD129A00419CD9 /* LocalReceiptParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalReceiptParserTests.swift; sourceTree = ""; }; 2DD448FD24088473002F5694 /* RCPurchases+SubscriberAttributes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "RCPurchases+SubscriberAttributes.h"; path = "Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.h"; sourceTree = SOURCE_ROOT; }; 2DD448FE24088473002F5694 /* RCPurchases+SubscriberAttributes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "RCPurchases+SubscriberAttributes.m"; path = "Purchases/SubscriberAttributes/RCPurchases+SubscriberAttributes.m"; sourceTree = SOURCE_ROOT; }; 2DD7BA4C24C63A830066B4C2 /* MockSystemInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSystemInfo.swift; sourceTree = ""; }; 2DDA3E4624DB0B5400EDFE5B /* OperationDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationDispatcher.swift; sourceTree = ""; }; + 2DDE559A24C8B5E300DCB087 /* verifyReceiptSample1.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = verifyReceiptSample1.txt; sourceTree = ""; }; + 2DDE559B24C8B5E300DCB087 /* base64encodedreceiptsample1.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = base64encodedreceiptsample1.txt; sourceTree = ""; }; 2DEB9766247DB46900A92099 /* RCISOPeriodFormatter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCISOPeriodFormatter.h; sourceTree = ""; }; 2DEB976A247DB85400A92099 /* SKProductSubscriptionDurationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKProductSubscriptionDurationExtensions.swift; sourceTree = ""; }; 2DEC0CFB24A2A1B100B0E5BB /* Package.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = SOURCE_ROOT; }; + 33FFC8744F2BAE7BD8889A4C /* Pods_Purchases.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Purchases.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 350A1B84226E3E8700CCA10F /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; 350FBDD71F7DF17F0065833D /* OHHTTPStubs.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OHHTTPStubs.framework; path = Carthage/Build/iOS/OHHTTPStubs.framework; sourceTree = SOURCE_ROOT; }; 350FBDDC1F7EA3570065833D /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Nimble.framework; path = Carthage/Build/iOS/Nimble.framework; sourceTree = SOURCE_ROOT; }; @@ -261,6 +303,7 @@ 37E35088AB7C4F7A242D3ED5 /* NSError+RCExtensions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSError+RCExtensions.m"; sourceTree = ""; }; 37E3508E52201122137D4B4A /* PurchasesSubscriberAttributesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PurchasesSubscriberAttributesTests.swift; sourceTree = ""; }; 37E3508EC20EEBAB4EAC4C82 /* NSDate+RCExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSDate+RCExtensionsTests.swift"; sourceTree = ""; }; + 37E35092F0E41512E0D610BA /* ContainerFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContainerFactory.swift; sourceTree = ""; }; 37E35097C6785A4A89DEBB18 /* NSData+RCExtensions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+RCExtensions.h"; sourceTree = ""; }; 37E3509E7B186A712097B8CF /* RCReceiptFetcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCReceiptFetcher.m; sourceTree = ""; }; 37E350B5671B69C9D9897A16 /* RCIdentityManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCIdentityManager.h; sourceTree = ""; }; @@ -268,48 +311,68 @@ 37E35105C5C36A30D084954C /* RCOfferingsFactory.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCOfferingsFactory.m; sourceTree = ""; }; 37E351221B2DFCE79A333401 /* RCIdentityManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCIdentityManager.m; sourceTree = ""; }; 37E35166EE0877FCCD3C0C2D /* RCAttributionFetcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCAttributionFetcher.m; sourceTree = ""; }; + 37E3518863ECACCDEE2D6F7B /* AppleReceiptBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleReceiptBuilder.swift; sourceTree = ""; }; + 37E3519B59165AF5FFD5D943 /* InAppPurchase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppPurchase.swift; sourceTree = ""; }; 37E351AC0FF9607719F7A29A /* MockStoreKitWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockStoreKitWrapper.swift; sourceTree = ""; }; 37E351CD1EE6B897F434EA40 /* MockAttributionFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockAttributionFetcher.swift; sourceTree = ""; }; 37E351CDC94FE0A49A3DAAB3 /* RCStoreKitRequestFetcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCStoreKitRequestFetcher.m; sourceTree = ""; }; + 37E351D0EBC4698E1D3585A6 /* ReceiptParserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReceiptParserTests.swift; sourceTree = ""; }; 37E351D1C68CCD8A82FB105D /* RCAttributionData.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCAttributionData.m; sourceTree = ""; }; 37E351D48260D9DC8B1EE360 /* MockSubscriberAttributesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockSubscriberAttributesManager.swift; sourceTree = ""; }; + 37E351EB3689AF304E5B1031 /* MockASN1ContainerBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockASN1ContainerBuilder.swift; sourceTree = ""; }; + 37E351F0E21361EAEC078A0D /* ProductsManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductsManagerTests.swift; sourceTree = ""; }; 37E3521D9235DD78BFEE8AC4 /* NSDate+RCExtensions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+RCExtensions.m"; sourceTree = ""; }; 37E352229DFDDA008BB029C2 /* RCLogUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCLogUtils.m; sourceTree = ""; }; 37E3524E3032ABC72467AA43 /* RCDeviceCache.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCDeviceCache.h; sourceTree = ""; }; 37E3528315F4F007250F62B2 /* NSLocale+RCExtensions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSLocale+RCExtensions.h"; sourceTree = ""; }; 37E35292137BBF2810CE4F4B /* MockHTTPClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockHTTPClient.swift; sourceTree = ""; }; + 37E352A24623FB706B93D9F1 /* ISO3601DateFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ISO3601DateFormatterTests.swift; sourceTree = ""; }; 37E352B11676F7DC51559E84 /* MockReceiptFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockReceiptFetcher.swift; sourceTree = ""; }; 37E352CB6BD9FE3D1F4946C5 /* RCOfferingsFactory.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCOfferingsFactory.h; sourceTree = ""; }; 37E352DAD631B3A45C041148 /* RCPurchases+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCPurchases+Protected.h"; sourceTree = ""; }; + 37E352E5B4549BB61C286B0D /* ArraySlice+ExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ArraySlice+ExtensionsTests.swift"; sourceTree = ""; }; 37E352F86A0A8EB05BAD77C4 /* StoreKitWrapperTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreKitWrapperTests.swift; sourceTree = ""; }; 37E352FDEEAD2E4EA0D2C16B /* RCDateProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCDateProvider.h; sourceTree = ""; }; 37E35331DBA29B3C17D0F507 /* RCHTTPStatusCodes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCHTTPStatusCodes.h; sourceTree = ""; }; 37E353471F474D9092560033 /* RCSystemInfo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCSystemInfo.h; sourceTree = ""; }; 37E353AF2CAD3CEDE6D9B368 /* NSError+RCExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSError+RCExtensionsTests.swift"; sourceTree = ""; }; 37E353CBE9CF2572A72A347F /* HTTPClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPClientTests.swift; sourceTree = ""; }; + 37E35408E083ECE8BD4D670C /* ArraySlice+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ArraySlice+Extensions.swift"; sourceTree = ""; }; + 37E35424E61D7A7EC1F457D4 /* AppleReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleReceipt.swift; sourceTree = ""; }; 37E3548189DA008320B3FC98 /* ProductInfoExtractorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductInfoExtractorTests.swift; sourceTree = ""; }; 37E354902741920FC5AE69A5 /* RCEntitlementInfos+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCEntitlementInfos+Protected.h"; sourceTree = ""; }; 37E354AF5D4E5FB2787B40DB /* IdentityManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityManagerTests.swift; sourceTree = ""; }; + 37E354B13440508B46C9A530 /* MockReceiptParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockReceiptParser.swift; sourceTree = ""; }; + 37E354B18710B488B8B0D443 /* IntroEligibilityCalculatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntroEligibilityCalculatorTests.swift; sourceTree = ""; }; 37E354BEB8FDE39CAB7C4D69 /* MockDeviceCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDeviceCache.swift; sourceTree = ""; }; 37E354D3420651DECC1EA656 /* RCOfferings+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCOfferings+Protected.h"; sourceTree = ""; }; 37E354E2E660D610B9CD4FA2 /* RCReceiptFetcher.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCReceiptFetcher.h; sourceTree = ""; }; 37E354FA06E2FDFB81EE3937 /* RCProductInfo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCProductInfo.h; sourceTree = ""; }; 37E3550C031F1FD95B366998 /* MockTransaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockTransaction.swift; sourceTree = ""; }; + 37E3553A641AADCAFE25AB3D /* AppleReceiptBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleReceiptBuilderTests.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 37E35645928A0009F4C105A7 /* MockBackend.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockBackend.swift; sourceTree = ""; }; + 37E35648821276317B23462E /* ASN1ObjectIdentifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1ObjectIdentifier.swift; sourceTree = ""; }; 37E35659EB530A5109AFAB50 /* MockOperationDispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockOperationDispatcher.swift; sourceTree = ""; }; + 37E3567189CF6A746EE3CCC2 /* DateExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; 37E3567271C2172DA3ED1B16 /* RCAttributionData.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCAttributionData.h; sourceTree = ""; }; 37E3567E972B9B04FE079ABA /* SusbcriberAttributesManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SusbcriberAttributesManagerTests.swift; sourceTree = ""; }; + 37E3569FAC5F05C94DB5FE3B /* UInt8+ExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UInt8+ExtensionsTests.swift"; sourceTree = ""; }; + 37E356AC37809AE1FDAF1344 /* InAppPurchaseBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppPurchaseBuilder.swift; sourceTree = ""; }; 37E356C39D29EB6C8EC2D6BD /* RCHTTPClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCHTTPClient.h; sourceTree = ""; }; + 37E356F27BD672DBD9F34897 /* ASN1ContainerBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1ContainerBuilder.swift; sourceTree = ""; }; 37E356F4B9FCED933CD9C1CA /* RCDeviceCache.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCDeviceCache.m; sourceTree = ""; }; 37E3571B552018D47A6ED7C6 /* MockUserManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockUserManager.swift; sourceTree = ""; }; 37E3572040A16F10B957563A /* MockProductDiscount.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockProductDiscount.swift; sourceTree = ""; }; + 37E3572FE503DFCEC4CCAD54 /* UInt8+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UInt8+Extensions.swift"; sourceTree = ""; }; 37E3578BD602C7B8E2274279 /* MockDateProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDateProvider.swift; sourceTree = ""; }; 37E357AE7E003DACF1635491 /* RCPromotionalOffer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCPromotionalOffer.h; sourceTree = ""; }; + 37E357B400D3028BC970DAC1 /* InAppPurchaseBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppPurchaseBuilderTests.swift; sourceTree = ""; }; 37E357C2D977BBB081216B5F /* OfferingsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OfferingsTests.swift; sourceTree = ""; }; 37E357C8623F79A4F0BEE213 /* RCOffering+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCOffering+Protected.h"; sourceTree = ""; }; 37E357D16038F07915D7825D /* MockUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; @@ -324,20 +387,28 @@ 37E359B77C1E588C9C009D2B /* RCStoreKitWrapper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCStoreKitWrapper.h; sourceTree = ""; }; 37E359D128E667D950598853 /* RCIntroEligibility+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCIntroEligibility+Protected.h"; sourceTree = ""; }; 37E359D8F304C83184560135 /* PurchaserInfoTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PurchaserInfoTests.swift; sourceTree = ""; }; + 37E359DD0D1582B18AF80690 /* ASN1Container.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1Container.swift; sourceTree = ""; }; 37E35A0D4A561C51185F82EB /* MockNotificationCenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockNotificationCenter.swift; sourceTree = ""; }; 37E35A6246A6969C7236B29B /* RCInMemoryCachedObject+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCInMemoryCachedObject+Protected.h"; sourceTree = ""; }; 37E35A6260C44FCBFF4CBB49 /* StoreKitRequestFetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreKitRequestFetcherTests.swift; sourceTree = ""; }; 37E35A6E091B8CD9C967383C /* RCBackend.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCBackend.h; sourceTree = ""; }; 37E35A7A70B77C0526EA3A28 /* RCSystemInfo.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCSystemInfo.m; sourceTree = ""; }; 37E35ABEE9FD79CCA64E4F8B /* NSData+RCExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSData+RCExtensionsTests.swift"; sourceTree = ""; }; + 37E35B08709090FBBFB16EBD /* MockProductsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockProductsRequest.swift; sourceTree = ""; }; 37E35B2CE711472BE58F51ED /* RCEntitlementInfo+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCEntitlementInfo+Protected.h"; sourceTree = ""; }; 37E35B655B1BA8F52C4349ED /* RCInMemoryCachedObject.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCInMemoryCachedObject.m; sourceTree = ""; }; 37E35B9AC7A350CA2437049D /* ISOPeriodFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ISOPeriodFormatterTests.swift; sourceTree = ""; }; 37E35BC489985F613020035D /* RCProductInfoExtractor.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCProductInfoExtractor.h; sourceTree = ""; }; + 37E35C1554F296F7F1317747 /* MockAppleReceiptBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockAppleReceiptBuilder.swift; sourceTree = ""; }; 37E35C1DFC68AC9E58E868F2 /* RCSubscriberAttribute+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCSubscriberAttribute+Protected.h"; sourceTree = ""; }; + 37E35C4A4B241A545D1D06BD /* ASN1ObjectIdentifierEncoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1ObjectIdentifierEncoder.swift; sourceTree = ""; }; 37E35C4A795A0F056381A1B3 /* DeviceCacheSubscriberAttributesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceCacheSubscriberAttributesTests.swift; sourceTree = ""; }; 37E35C5A65AAF701DED59800 /* MockInMemoryCachedOfferings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockInMemoryCachedOfferings.swift; sourceTree = ""; }; 37E35C6CBB3229AA53ECEB58 /* RCInMemoryCachedObject.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCInMemoryCachedObject.h; sourceTree = ""; }; + 37E35C7060D7E486F5958BED /* ProductsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = ""; }; + 37E35C8542B5C9C2749040D0 /* ASN1ObjectIdentifierBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1ObjectIdentifierBuilderTests.swift; sourceTree = ""; }; + 37E35C9439E087F63ECC4F59 /* MockProductsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockProductsManager.swift; sourceTree = ""; }; + 37E35CB72101C1CA642C101C /* ASN1ContainerBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1ContainerBuilderTests.swift; sourceTree = ""; }; 37E35CC6289D46E544F5F006 /* RCISOPeriodFormatter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCISOPeriodFormatter.m; sourceTree = ""; }; 37E35CE6628D07BD2C4A07C0 /* RCStoreKitWrapper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCStoreKitWrapper.m; sourceTree = ""; }; 37E35CF71E13B58F4AC8998F /* RCPurchasesErrorUtils+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCPurchasesErrorUtils+Protected.h"; sourceTree = ""; }; @@ -352,13 +423,18 @@ 37E35E282619939718FC7DBE /* RCBackend.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCBackend.m; sourceTree = ""; }; 37E35E3250FBBB03D92E06EC /* InMemoryCachedObjectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryCachedObjectTests.swift; sourceTree = ""; }; 37E35E5C3D1B1D6814496C89 /* RCProductInfoExtractor.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCProductInfoExtractor.m; sourceTree = ""; }; + 37E35E8DCF998D9DB63850F8 /* ProductsRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductsRequestFactory.swift; sourceTree = ""; }; 37E35EABF6D7AFE367718784 /* MockSKDiscount.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockSKDiscount.swift; sourceTree = ""; }; 37E35EB9B60477A7AD2C2EB5 /* RCLogUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCLogUtils.h; sourceTree = ""; }; 37E35EDEA372C1D8E03118FF /* RCHTTPClient.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCHTTPClient.m; sourceTree = ""; }; 37E35EEE7783629CDE41B70C /* SystemInfoTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemInfoTests.swift; sourceTree = ""; }; 37E35F1729999CFCA36E877D /* RCDateProvider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCDateProvider.m; sourceTree = ""; }; 37E35F19DF256C1D0125CC76 /* RCPurchaserInfo+Protected.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCPurchaserInfo+Protected.h"; sourceTree = ""; }; + 37E35F419D9D56D79369D690 /* ISO3601DateFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ISO3601DateFormatter.swift; sourceTree = ""; }; + 37E35F783903362B65FB7AF3 /* MockProductsRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockProductsRequestFactory.swift; sourceTree = ""; }; + 37E35FCCA2447C2A93C37431 /* ASN1ObjectIdentifierBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ASN1ObjectIdentifierBuilder.swift; sourceTree = ""; }; 37E35FDC209D3F4C38791BDE /* RCProductInfo.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCProductInfo.m; sourceTree = ""; }; + 84C3F1AC1D7E1E64341D3936 /* Pods_Purchases_PurchasesTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Purchases_PurchasesTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -403,7 +479,11 @@ 2D1A28CB24AA6F4B006BE931 /* LocalReceiptParsing */ = { isa = PBXGroup; children = ( - 2D1A28CC24AA6F81006BE931 /* LocalReceiptParser.swift */, + 2D5BB46A24C8E8ED00E27537 /* ReceiptParser.swift */, + 2D8F622224D30F9D00F993AA /* ReceiptParsingError.swift */, + 37E35FCF87558ACB498521F1 /* BasicTypes */, + 37E355596456B3DFA01EF081 /* Builders */, + 37E35556F2D7B8B28B169C77 /* DataConverters */, ); path = LocalReceiptParsing; sourceTree = ""; @@ -431,6 +511,7 @@ 2D1A28CB24AA6F4B006BE931 /* LocalReceiptParsing */, 2DC5621824EC63430031F69B /* PurchasesCoreSwift.h */, 2DC5621924EC63430031F69B /* Info.plist */, + 2D97458E24BDFCEF006245E9 /* IntroEligibilityCalculator.swift */, ); path = PurchasesCoreSwift; sourceTree = ""; @@ -441,10 +522,21 @@ 354235D624C11160008C84EE /* Purchasing */, 2DD02D5924AD128A00419CD9 /* LocalReceiptParsing */, 2DC5622524EC63430031F69B /* Info.plist */, + 354235D624C11160008C84EE /* Purchasing */, + 2DD02D5924AD128A00419CD9 /* LocalReceiptParsing */, + 37E354B18710B488B8B0D443 /* IntroEligibilityCalculatorTests.swift */, ); path = PurchasesCoreSwiftTests; sourceTree = ""; }; + 2DA5A4AF24DC8AAD000D2932 /* TestsAgainstRealReceipts */ = { + isa = PBXGroup; + children = ( + 2DA5A4B024DC8AD1000D2932 /* ReceiptParsing+TestsWithRealReceipts.swift */, + ); + path = TestsAgainstRealReceipts; + sourceTree = ""; + }; 2DCB85BF2406EC3F003C1260 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -455,7 +547,10 @@ 2DD02D5924AD128A00419CD9 /* LocalReceiptParsing */ = { isa = PBXGroup; children = ( - 2DD02D5A24AD129A00419CD9 /* LocalReceiptParserTests.swift */, + 2DA5A4AF24DC8AAD000D2932 /* TestsAgainstRealReceipts */, + 37E354D3E31C6FDB3073641A /* DataConverters */, + 37E35C3FD4A36981CEBFD229 /* Builders */, + 37E351D0EBC4698E1D3585A6 /* ReceiptParserTests.swift */, ); path = LocalReceiptParsing; sourceTree = ""; @@ -464,10 +559,28 @@ isa = PBXGroup; children = ( 2DDA3E4624DB0B5400EDFE5B /* OperationDispatcher.swift */, + 37E3567189CF6A746EE3CCC2 /* DateExtensions.swift */, ); path = Misc; sourceTree = ""; }; + 2DDE559824C8B5D100DCB087 /* Resources */ = { + isa = PBXGroup; + children = ( + 2DDE559924C8B5E300DCB087 /* receipts */, + ); + path = Resources; + sourceTree = ""; + }; + 2DDE559924C8B5E300DCB087 /* receipts */ = { + isa = PBXGroup; + children = ( + 2DDE559A24C8B5E300DCB087 /* verifyReceiptSample1.txt */, + 2DDE559B24C8B5E300DCB087 /* base64encodedreceiptsample1.txt */, + ); + path = receipts; + sourceTree = ""; + }; 350FBDD61F7DF1640065833D /* Frameworks */ = { isa = PBXGroup; children = ( @@ -550,6 +663,8 @@ 35262A1F1F7D77E600C04F2C /* PurchasesTests */ = { isa = PBXGroup; children = ( + 2DDE559824C8B5D100DCB087 /* Resources */, + 2DD02D5824AD128000419CD9 /* SwiftSources */, 35262A291F7D783F00C04F2C /* PurchasesTests-Bridging-Header.h */, 35262A221F7D77E600C04F2C /* Info.plist */, 350FBDD61F7DF1640065833D /* Frameworks */, @@ -560,6 +675,7 @@ 37E35F2DF6910CF4AF147DEB /* Networking */, 37E353592BA71F362DD61153 /* FoundationExtensions */, 37E35FF455726D96C243B1B7 /* Misc */, + 37E35A5970D1604E8C8011FC /* TestHelpers */, ); path = PurchasesTests; sourceTree = ""; @@ -570,6 +686,8 @@ 357C9BC022725CFA006BC624 /* iAd.framework */, 350A1B84226E3E8700CCA10F /* AppKit.framework */, 3530C18822653E8F00D6DF52 /* AdSupport.framework */, + 33FFC8744F2BAE7BD8889A4C /* Pods_Purchases.framework */, + 84C3F1AC1D7E1E64341D3936 /* Pods_Purchases_PurchasesTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -578,6 +696,9 @@ isa = PBXGroup; children = ( 3597020F24BF6A710010506E /* TransactionsFactory.swift */, + 35A6DC1824BE5FF100C3983D /* Transaction.swift */, + 37E35C7060D7E486F5958BED /* ProductsManager.swift */, + 37E35E8DCF998D9DB63850F8 /* ProductsRequestFactory.swift */, ); path = Purchasing; sourceTree = ""; @@ -586,6 +707,7 @@ isa = PBXGroup; children = ( 3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */, + 37E351F0E21361EAEC078A0D /* ProductsManagerTests.swift */, ); path = Purchasing; sourceTree = ""; @@ -593,6 +715,7 @@ 37E35081077192045E3A8080 /* Mocks */ = { isa = PBXGroup; children = ( + 2DA0068E24E2E515002C59D3 /* MockIntroEligibilityCalculator.swift */, 37E354BEB8FDE39CAB7C4D69 /* MockDeviceCache.swift */, 37E357D16038F07915D7825D /* MockUserDefaults.swift */, 37E35C5A65AAF701DED59800 /* MockInMemoryCachedOfferings.swift */, @@ -615,6 +738,13 @@ 37E35EABF6D7AFE367718784 /* MockSKDiscount.swift */, 2DD7BA4C24C63A830066B4C2 /* MockSystemInfo.swift */, 37E35659EB530A5109AFAB50 /* MockOperationDispatcher.swift */, + 37E355744D64075AA91342DE /* MockInAppPurchaseBuilder.swift */, + 37E35C1554F296F7F1317747 /* MockAppleReceiptBuilder.swift */, + 37E351EB3689AF304E5B1031 /* MockASN1ContainerBuilder.swift */, + 37E35C9439E087F63ECC4F59 /* MockProductsManager.swift */, + 37E354B13440508B46C9A530 /* MockReceiptParser.swift */, + 37E35F783903362B65FB7AF3 /* MockProductsRequestFactory.swift */, + 37E35B08709090FBBFB16EBD /* MockProductsRequest.swift */, ); path = Mocks; sourceTree = ""; @@ -662,6 +792,16 @@ path = FoundationExtensions; sourceTree = ""; }; + 37E354D3E31C6FDB3073641A /* DataConverters */ = { + isa = PBXGroup; + children = ( + 37E3569FAC5F05C94DB5FE3B /* UInt8+ExtensionsTests.swift */, + 37E352E5B4549BB61C286B0D /* ArraySlice+ExtensionsTests.swift */, + 37E352A24623FB706B93D9F1 /* ISO3601DateFormatterTests.swift */, + ); + path = DataConverters; + sourceTree = ""; + }; 37E35555C264F76E6CFFC2C8 /* Purchasing */ = { isa = PBXGroup; children = ( @@ -680,6 +820,27 @@ path = Purchasing; sourceTree = ""; }; + 37E35556F2D7B8B28B169C77 /* DataConverters */ = { + isa = PBXGroup; + children = ( + 37E35408E083ECE8BD4D670C /* ArraySlice+Extensions.swift */, + 37E35F419D9D56D79369D690 /* ISO3601DateFormatter.swift */, + 37E3572FE503DFCEC4CCAD54 /* UInt8+Extensions.swift */, + ); + path = DataConverters; + sourceTree = ""; + }; + 37E355596456B3DFA01EF081 /* Builders */ = { + isa = PBXGroup; + children = ( + 37E35FCCA2447C2A93C37431 /* ASN1ObjectIdentifierBuilder.swift */, + 37E356F27BD672DBD9F34897 /* ASN1ContainerBuilder.swift */, + 37E356AC37809AE1FDAF1344 /* InAppPurchaseBuilder.swift */, + 37E3518863ECACCDEE2D6F7B /* AppleReceiptBuilder.swift */, + ); + path = Builders; + sourceTree = ""; + }; 37E359927177DF24576FF361 /* Caching */ = { isa = PBXGroup; children = ( @@ -691,6 +852,15 @@ path = Caching; sourceTree = ""; }; + 37E35A5970D1604E8C8011FC /* TestHelpers */ = { + isa = PBXGroup; + children = ( + 37E35C4A4B241A545D1D06BD /* ASN1ObjectIdentifierEncoder.swift */, + 37E35092F0E41512E0D610BA /* ContainerFactory.swift */, + ); + path = TestHelpers; + sourceTree = ""; + }; 37E35AE0CDC4C2AA8260FB58 /* Caching */ = { isa = PBXGroup; children = ( @@ -727,6 +897,17 @@ path = Purchasing; sourceTree = ""; }; + 37E35C3FD4A36981CEBFD229 /* Builders */ = { + isa = PBXGroup; + children = ( + 37E35CB72101C1CA642C101C /* ASN1ContainerBuilderTests.swift */, + 37E3553A641AADCAFE25AB3D /* AppleReceiptBuilderTests.swift */, + 37E357B400D3028BC970DAC1 /* InAppPurchaseBuilderTests.swift */, + 37E35C8542B5C9C2749040D0 /* ASN1ObjectIdentifierBuilderTests.swift */, + ); + path = Builders; + sourceTree = ""; + }; 37E35E77A60AC8D3F0E1A23D /* SubscriberAttributes */ = { isa = PBXGroup; children = ( @@ -779,6 +960,17 @@ path = Networking; sourceTree = ""; }; + 37E35FCF87558ACB498521F1 /* BasicTypes */ = { + isa = PBXGroup; + children = ( + 37E3519B59165AF5FFD5D943 /* InAppPurchase.swift */, + 37E359DD0D1582B18AF80690 /* ASN1Container.swift */, + 37E35648821276317B23462E /* ASN1ObjectIdentifier.swift */, + 37E35424E61D7A7EC1F457D4 /* AppleReceipt.swift */, + ); + path = BasicTypes; + sourceTree = ""; + }; 37E35FF455726D96C243B1B7 /* Misc */ = { isa = PBXGroup; children = ( @@ -1017,6 +1209,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2D0B060324D0C8F8007BB162 /* base64encodedreceiptsample1.txt in Resources */, + 2D0B060524D0C8FF007BB162 /* verifyReceiptSample1.txt in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1086,6 +1280,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2DB5F18624E47D00002FB655 /* ProductsRequestFactory.swift in Sources */, 35B54E4622EA6F11005918B1 /* RCEntitlementInfo.m in Sources */, 3585D6A422E680E30079E2C5 /* RCPackage.m in Sources */, 35B54E4C22EA6FD3005918B1 /* RCEntitlementInfos.m in Sources */, @@ -1099,11 +1294,13 @@ 354D50E022E7D014009B870C /* RCOfferings.m in Sources */, 35CE74FD20C38A7100CE09D8 /* RCOffering.m in Sources */, 37E35C90AAEFEA77BB48DC0F /* NSData+RCExtensions.m in Sources */, + 2D8F622324D30F9D00F993AA /* ReceiptParsingError.swift in Sources */, 37E3556DD7FB5317A43086CC /* NSDate+RCExtensions.m in Sources */, 37E35B4BDE7D7B1EFB3EE800 /* NSError+RCExtensions.m in Sources */, 37E35024A14FBDD9A5DF0B7C /* NSLocale+RCExtensions.m in Sources */, 37E352E9231D083B78E2E345 /* RCBackend.m in Sources */, 37E35D561B3F5AC49FD6C2CF /* RCHTTPClient.m in Sources */, + 2D97458F24BDFCEF006245E9 /* IntroEligibilityCalculator.swift in Sources */, 37E35EB1B9FE3F739B8C0D71 /* RCDateProvider.m in Sources */, 37E35DC1B42094073EB105AC /* RCSystemInfo.m in Sources */, 37E352BAA55C31B1E278CA8A /* RCLogUtils.m in Sources */, @@ -1119,8 +1316,22 @@ 37E35A7ED3312F10B80FFE2B /* RCDeviceCache.m in Sources */, 37E353437E49AA49D6ECC03F /* RCInMemoryCachedObject.m in Sources */, 37E351B84EBE06E2F370A835 /* RCISOPeriodFormatter.m in Sources */, + 2D5BB46B24C8E8ED00E27537 /* ReceiptParser.swift in Sources */, 37E3506DF4FDFCE5CA75B057 /* RCProductInfo.m in Sources */, 37E353FA89D4F510BA73F0D3 /* RCProductInfoExtractor.m in Sources */, + 37E35E7A9AA2305A48463AFC /* InAppPurchase.swift in Sources */, + 37E35762A6E3324312EAB2BB /* ASN1Container.swift in Sources */, + 37E35D841A9E1DD53D34231A /* ASN1ObjectIdentifier.swift in Sources */, + 37E35A685C0E1C7FD8B7E6D8 /* AppleReceipt.swift in Sources */, + 37E356644B9A1601CD40D7E0 /* ASN1ObjectIdentifierBuilder.swift in Sources */, + 37E3528ABEF11A98469768CD /* ASN1ContainerBuilder.swift in Sources */, + 37E355AB4CA0C4BFA6154EE6 /* InAppPurchaseBuilder.swift in Sources */, + 37E358E535E53D385DF442FD /* AppleReceiptBuilder.swift in Sources */, + 37E358EC3900F272E94D40AC /* ArraySlice+Extensions.swift in Sources */, + 37E35BB7BA59E7B94A47F6B2 /* ISO3601DateFormatter.swift in Sources */, + 37E35E0287F4583D0BF8A22B /* UInt8+Extensions.swift in Sources */, + 37E35FCBAA6368002BF4A661 /* DateExtensions.swift in Sources */, + 37E35DAB16813ABB5728A452 /* ProductsManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1130,6 +1341,8 @@ files = ( 2D8DB34B24072AAE00BE3D31 /* SubscriberAttributeTests.swift in Sources */, 3589D15424C219BE00A65CBB /* AttributionFetcherTests.swift in Sources */, + 2D33A66224D8B67400893BD4 /* ASN1ContainerBuilderTests.swift in Sources */, + 2DA0069024E32A33002C59D3 /* IntroEligibilityCalculatorTests.swift in Sources */, 2DEB976B247DB85400A92099 /* SKProductSubscriptionDurationExtensions.swift in Sources */, 37E354BE25CE61E55E4FD89C /* MockDeviceCache.swift in Sources */, 37E3585E0B39722838F235BD /* MockUserDefaults.swift in Sources */, @@ -1137,6 +1350,7 @@ 37E351E3AC0B5F67305B4CB6 /* DeviceCacheTests.swift in Sources */, 37E35AF213F2F79CB067FDC2 /* InMemoryCachedObjectTests.swift in Sources */, 37E353851D42047D5B0A57D0 /* MockDateProvider.swift in Sources */, + 2D33A66124D899AD00893BD4 /* UInt8+ExtensionsTests.swift in Sources */, 37E355E7EB509047724841F4 /* BackendSubscriberAttributesTests.swift in Sources */, 2DD02D5724AD0B0500419CD9 /* RCIntroEligibilityTests.swift in Sources */, 37E354C46A8A3C2DC861C224 /* MockHTTPClient.swift in Sources */, @@ -1146,9 +1360,12 @@ 37E35DD380900220C34BB222 /* MockTransaction.swift in Sources */, 37E3554836D4DA9336B9FA70 /* MockProductDiscount.swift in Sources */, 37E35398FCB4931573C56CAF /* MockReceiptFetcher.swift in Sources */, + 2D8D134824DE0AE900B7EAE9 /* InAppPurchaseBuilderTests.swift in Sources */, + 3597021224BF6AAC0010506E /* TransactionsFactoryTests.swift in Sources */, 37E35F20FB949985BEEB4B58 /* MockRequestFetcher.swift in Sources */, 37E35AD0B0D9EF0CDA29DAC2 /* MockStoreKitWrapper.swift in Sources */, 37E35EBDFC5CD3068E1792A3 /* MockNotificationCenter.swift in Sources */, + 2DA5A4B124DC8AD1000D2932 /* ReceiptParsing+TestsWithRealReceipts.swift in Sources */, 37E354E0A9A371481540B2B0 /* MockAttributionFetcher.swift in Sources */, 37E35EDC57C486AC2D66B4B8 /* MockOfferingsFactory.swift in Sources */, 2DD7BA4D24C63A830066B4C2 /* MockSystemInfo.swift in Sources */, @@ -1176,6 +1393,22 @@ 37E35F0387D0ADE014186924 /* ProductInfoExtractorTests.swift in Sources */, 37E351505CB4764821451E27 /* ProductInfoExtensions.swift in Sources */, 37E3599326581376E0142EEC /* SystemInfoTests.swift in Sources */, + 37E355DF6C7226308E3B0BD0 /* ArraySlice+ExtensionsTests.swift in Sources */, + 37E350940C1C75D6DFFB3F94 /* ISO3601DateFormatterTests.swift in Sources */, + 37E35F89A7F467364EAF5640 /* AppleReceiptBuilderTests.swift in Sources */, + 37E35BD808CF7DE8960ACFBE /* ASN1ObjectIdentifierBuilderTests.swift in Sources */, + 2DA0068F24E2E515002C59D3 /* MockIntroEligibilityCalculator.swift in Sources */, + 37E35EB2CCADA3EA3BCAEBA5 /* MockInAppPurchaseBuilder.swift in Sources */, + 37E352554834F52537E5704F /* ReceiptParserTests.swift in Sources */, + 37E350064866FDD37B6E6BA9 /* MockAppleReceiptBuilder.swift in Sources */, + 37E3515B3BCEADD0C3B07470 /* MockASN1ContainerBuilder.swift in Sources */, + 37E35AB21E374E375F60E92A /* ASN1ObjectIdentifierEncoder.swift in Sources */, + 37E35EDA5BAF7112FC2EAFC2 /* ContainerFactory.swift in Sources */, + 37E35096F00109F827375249 /* MockProductsManager.swift in Sources */, + 37E3566468428D32D732D625 /* MockReceiptParser.swift in Sources */, + 37E354D42DC8EEA3D195CD2C /* MockProductsRequestFactory.swift in Sources */, + 37E3560FE1D909EA2559FF5B /* ProductsManagerTests.swift in Sources */, + 37E35E835B0D043B78CCFBD3 /* MockProductsRequest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Purchases/ProtectedExtensions/RCPurchases+Protected.h b/Purchases/ProtectedExtensions/RCPurchases+Protected.h index 000bb4b0cf..dd6e6496d1 100644 --- a/Purchases/ProtectedExtensions/RCPurchases+Protected.h +++ b/Purchases/ProtectedExtensions/RCPurchases+Protected.h @@ -18,7 +18,8 @@ RCIdentityManager, RCSubscriberAttributesManager, RCSystemInfo, - RCOperationDispatcher; + RCOperationDispatcher, + RCIntroEligibilityCalculator; NS_ASSUME_NONNULL_BEGIN @@ -38,7 +39,8 @@ NS_ASSUME_NONNULL_BEGIN deviceCache:(RCDeviceCache *)deviceCache identityManager:(RCIdentityManager *)identityManager subscriberAttributesManager:(RCSubscriberAttributesManager *)subscriberAttributesManager - operationDispatcher:(RCOperationDispatcher *)operationDispatcher; + operationDispatcher:(RCOperationDispatcher *)operationDispatcher + introEligibilityCalculator:(RCIntroEligibilityCalculator *)introEligibilityCalculator; + (void)setDefaultInstance:(nullable RCPurchases *)instance; diff --git a/Purchases/Public/RCPurchases.m b/Purchases/Public/RCPurchases.m index a2d1c2b8e7..ebfc6b78ac 100644 --- a/Purchases/Public/RCPurchases.m +++ b/Purchases/Public/RCPurchases.m @@ -65,6 +65,7 @@ @interface RCPurchases () { @property (nonatomic) RCIdentityManager *identityManager; @property (nonatomic) RCSystemInfo *systemInfo; @property (nonatomic) RCOperationDispatcher *operationDispatcher; +@property (nonatomic) RCIntroEligibilityCalculator *introEligibilityCalculator; @end @@ -223,7 +224,8 @@ - (instancetype)initWithAPIKey:(NSString *)APIKey [[RCSubscriberAttributesManager alloc] initWithBackend:backend deviceCache:deviceCache]; RCOperationDispatcher *operationDispatcher = [[RCOperationDispatcher alloc] init]; - + RCIntroEligibilityCalculator *introCalculator = [[RCIntroEligibilityCalculator alloc] init]; + return [self initWithAppUserID:appUserID requestFetcher:fetcher receiptFetcher:receiptFetcher @@ -237,7 +239,8 @@ - (instancetype)initWithAPIKey:(NSString *)APIKey deviceCache:deviceCache identityManager:identityManager subscriberAttributesManager:subscriberAttributesManager - operationDispatcher:operationDispatcher]; + operationDispatcher:operationDispatcher + introEligibilityCalculator:introCalculator]; } - (instancetype)initWithAppUserID:(nullable NSString *)appUserID @@ -248,13 +251,13 @@ - (instancetype)initWithAppUserID:(nullable NSString *)appUserID storeKitWrapper:(RCStoreKitWrapper *)storeKitWrapper notificationCenter:(NSNotificationCenter *)notificationCenter userDefaults:(NSUserDefaults *)userDefaults - systemInfo:systemInfo + systemInfo:(RCSystemInfo *)systemInfo offeringsFactory:(RCOfferingsFactory *)offeringsFactory deviceCache:(RCDeviceCache *)deviceCache identityManager:(RCIdentityManager *)identityManager subscriberAttributesManager:(RCSubscriberAttributesManager *)subscriberAttributesManager - operationDispatcher:(RCOperationDispatcher *)operationDispatcher { - + operationDispatcher:(RCOperationDispatcher *)operationDispatcher + introEligibilityCalculator:(RCIntroEligibilityCalculator *)introEligibilityCalculator { if (self = [super init]) { RCDebugLog(@"Debug logging enabled."); RCDebugLog(@"SDK Version - %@", self.class.frameworkVersion); @@ -279,6 +282,7 @@ - (instancetype)initWithAppUserID:(nullable NSString *)appUserID self.systemInfo = systemInfo; self.subscriberAttributesManager = subscriberAttributesManager; self.operationDispatcher = operationDispatcher; + self.introEligibilityCalculator = introEligibilityCalculator; RCReceivePurchaserInfoBlock callDelegate = ^void(RCPurchaserInfo *info, NSError *error) { if (info) { @@ -660,32 +664,43 @@ - (void)handleRestoreReceiptPostWithInfo:(RCPurchaserInfo *)info } - (void)checkTrialOrIntroductoryPriceEligibility:(NSArray *)productIdentifiers - completionBlock:(RCReceiveIntroEligibilityBlock)receiveEligibility { - [self receiptData:^(NSData * _Nonnull data) { - RCLocalReceiptParser *receiptParser = [[RCLocalReceiptParser alloc] init]; - [receiptParser checkTrialOrIntroductoryPriceEligibilityWithData:data - productIdentifiers:productIdentifiers - completion:^(NSDictionary * _Nonnull receivedEligibility, - NSError * _Nullable error) { - if (!error) { - NSMutableDictionary *convertedEligibility = [[NSMutableDictionary alloc] init]; - - for (NSString *key in receivedEligibility.allKeys) { - convertedEligibility[key] = [[RCIntroEligibility alloc] initWithEligibilityStatusCode:receivedEligibility[key]]; - } - - CALL_IF_SET_ON_MAIN_THREAD(receiveEligibility, convertedEligibility); - } else { - NSLog(@"There was an error when trying to parse the receipt locally, details: %@", error.localizedDescription); - [self.backend getIntroEligibilityForAppUserID:self.appUserID - receiptData:data - productIdentifiers:productIdentifiers - completion:^(NSDictionary * _Nonnull result) { - CALL_IF_SET_ON_MAIN_THREAD(receiveEligibility, result); + completionBlock:(RCReceiveIntroEligibilityBlock)receiveEligibility +{ + [self receiptData:^(NSData *data) { + if (data != nil && data.length > 0) { + if (@available(iOS 12.0, macOS 10.14, macCatalyst 13.0, tvOS 12.0, watchOS 6.2, *)) { + NSSet *productIdentifiersSet = [[NSSet alloc] initWithArray:productIdentifiers]; + [self.introEligibilityCalculator checkTrialOrIntroductoryPriceEligibilityWithData:data + productIdentifiers:productIdentifiersSet + completion:^(NSDictionary * _Nonnull receivedEligibility, + NSError * _Nullable error) { + if (!error) { + NSMutableDictionary *convertedEligibility = [[NSMutableDictionary alloc] init]; + + for (NSString *key in receivedEligibility.allKeys) { + convertedEligibility[key] = [[RCIntroEligibility alloc] initWithEligibilityStatusCode:receivedEligibility[key]]; + } + + CALL_IF_SET_ON_MAIN_THREAD(receiveEligibility, convertedEligibility); + } else { + NSLog(@"There was an error when trying to parse the receipt locally, details: %@", error.localizedDescription); + [self.backend getIntroEligibilityForAppUserID:self.appUserID + receiptData:data + productIdentifiers:productIdentifiers + completion:^(NSDictionary * _Nonnull result) { + CALL_IF_SET_ON_MAIN_THREAD(receiveEligibility, result); + }]; + } }]; } - }]; - + } else { + [self.backend getIntroEligibilityForAppUserID:self.appUserID + receiptData:data + productIdentifiers:productIdentifiers + completion:^(NSDictionary * _Nonnull result) { + CALL_IF_SET_ON_MAIN_THREAD(receiveEligibility, result); + }]; + } }]; } diff --git a/Purchases/SwiftInterfaces/RCOperationDispatcher.h b/Purchases/SwiftInterfaces/RCOperationDispatcher.h new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Purchases/SwiftInterfaces/RCTransactionsFactory.h b/Purchases/SwiftInterfaces/RCTransactionsFactory.h new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Purchases/SwiftSources/IntroEligibilityCalculator.swift b/Purchases/SwiftSources/IntroEligibilityCalculator.swift new file mode 100644 index 0000000000..f01c8d8803 --- /dev/null +++ b/Purchases/SwiftSources/IntroEligibilityCalculator.swift @@ -0,0 +1,94 @@ +// +// IntroEligibilityCalculator.swift +// Purchases +// +// Created by Andrés Boedo on 7/14/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation +import StoreKit + +@objc(RCIntroEligibilityCalculator) class IntroEligibilityCalculator: NSObject { + private let productsManager: ProductsManager + private let receiptParser: ReceiptParser + + override init() { + self.productsManager = ProductsManager() + self.receiptParser = ReceiptParser() + } + + internal init(productsManager: ProductsManager, + receiptParser: ReceiptParser) { + self.productsManager = productsManager + self.receiptParser = receiptParser + } + + @available(iOS 12.0, macOS 10.14, macCatalyst 13.0, tvOS 12.0, watchOS 6.2, *) + @objc func checkTrialOrIntroductoryPriceEligibility(with receiptData: Data, + productIdentifiers candidateProductIdentifiers: Set, + completion: @escaping ([String: NSNumber], Error?) -> Void) { + guard candidateProductIdentifiers.count > 0 else { + completion([:], nil) + return + } + + var result: [String: NSNumber] = candidateProductIdentifiers.reduce(into: [:]) { resultDict, productId in + resultDict[productId] = IntroEligibilityStatus.unknown.toNSNumber() + } + do { + let receipt = try receiptParser.parse(from: receiptData) + let purchasedProductIdsWithIntroOffers = receipt.purchasedIntroOfferOrFreeTrialProductIdentifiers() + + let allProductIdentifiers = candidateProductIdentifiers.union(purchasedProductIdsWithIntroOffers) + + productsManager.products(withIdentifiers: allProductIdentifiers) { allProducts in + let purchasedProductsWithIntroOffers = allProducts.filter { purchasedProductIdsWithIntroOffers.contains($0.productIdentifier) } + let candidateProducts = allProducts.filter { candidateProductIdentifiers.contains($0.productIdentifier) } + + let eligibility: [String: NSNumber] = self.checkIntroEligibility(candidateProducts: candidateProducts, + purchasedProductsWithIntroOffers: purchasedProductsWithIntroOffers) + result.merge(eligibility) { (_, new) in new } + + completion(result, nil) + } + } + catch let error { + completion([:], error) + return + } + } +} + +@available(iOS 12.0, macOS 10.14, macCatalyst 13.0, tvOS 12.0, watchOS 6.2, *) +private extension IntroEligibilityCalculator { + + func checkIntroEligibility(candidateProducts: Set, + purchasedProductsWithIntroOffers: Set) -> [String: NSNumber] { + var result: [String: NSNumber] = [:] + for candidate in candidateProducts { + let usedIntroForProductIdentifier = purchasedProductsWithIntroOffers + .contains { purchased in + let foundByGroupId = (candidate.subscriptionGroupIdentifier != nil + && candidate.subscriptionGroupIdentifier == purchased.subscriptionGroupIdentifier) + return foundByGroupId + } + result[candidate.productIdentifier] = usedIntroForProductIdentifier + ? IntroEligibilityStatus.ineligible.toNSNumber() + : IntroEligibilityStatus.eligible.toNSNumber() + } + return result + } +} + +enum IntroEligibilityStatus: Int { + case unknown, + ineligible, + eligible +} + +extension IntroEligibilityStatus { + func toNSNumber() -> NSNumber { + return NSNumber(integerLiteral: self.rawValue) + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1Container.swift b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1Container.swift new file mode 100644 index 0000000000..244d9be99f --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1Container.swift @@ -0,0 +1,62 @@ +// +// Created by Andrés Boedo on 7/28/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +enum ASN1Class: UInt8 { + case universal, application, contextSpecific, `private` +} + +enum ASN1Identifier: UInt8, CaseIterable { + case endOfContent = 0x00 + case boolean = 1 + case integer = 2 + case bitString = 3 + case octetString = 4 + case null = 5 + case objectIdentifier = 6 + case objectDescriptor = 7 + case external = 8 + case real = 9 + case enumerated = 10 + case embeddedPdv = 11 + case utf8String = 12 + case relativeOid = 13 + case sequence = 16 + case set = 17 + case numericString = 18 + case printableString = 19 + case t61String = 20 + case videotexString = 21 + case ia5String = 22 + case utcTime = 23 + case generalizedTime = 24 + case graphicString = 25 + case visibleString = 26 + case generalString = 27 + case universalString = 28 + case characterString = 29 + case bmpString = 30 +} + +enum ASN1EncodingType: UInt8 { + case primitive, constructed +} + +struct ASN1Length: Equatable { + let value: Int + let bytesUsedForLength: Int +} + +struct ASN1Container: Equatable { + let containerClass: ASN1Class + let containerIdentifier: ASN1Identifier + let encodingType: ASN1EncodingType + let length: ASN1Length + let internalPayload: ArraySlice + let bytesUsedForIdentifier = 1 + var totalBytesUsed: Int { return bytesUsedForIdentifier + length.value + length.bytesUsedForLength } + let internalContainers: [ASN1Container] +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift new file mode 100644 index 0000000000..08590896ec --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/ASN1ObjectIdentifier.swift @@ -0,0 +1,16 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +// http://www.umich.edu/~x509/ssleay/asn1-oids.html +enum ASN1ObjectIdentifier: String { + case data = "1.2.840.113549.1.7.1" + case signedData = "1.2.840.113549.1.7.2" + case envelopedData = "1.2.840.113549.1.7.3" + case signedAndEnvelopedData = "1.2.840.113549.1.7.4" + case digestedData = "1.2.840.113549.1.7.5" + case encryptedData = "1.2.840.113549.1.7.6" +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift new file mode 100644 index 0000000000..40de480b4e --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/AppleReceipt.swift @@ -0,0 +1,63 @@ +// +// AppleReceipt.swift +// Purchases +// +// Created by Andrés Boedo on 7/22/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation + +// https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html +struct ReceiptAttribute { + let type: ReceiptAttributeType + let version: Int + let value: String +} + +enum ReceiptAttributeType: Int { + case bundleId = 2, + applicationVersion = 3, + opaqueValue = 4, + sha1Hash = 5, + creationDate = 12, + inAppPurchase = 17, + originalApplicationVersion = 19, + expirationDate = 21 +} + +struct AppleReceipt: Equatable { + let bundleId: String + let applicationVersion: String + let originalApplicationVersion: String + let opaqueValue: Data + let sha1Hash: Data + let creationDate: Date + let expirationDate: Date? + let inAppPurchases: [InAppPurchase] + + func purchasedIntroOfferOrFreeTrialProductIdentifiers() -> Set { + let productIdentifiers = inAppPurchases + .filter { $0.isInIntroOfferPeriod || $0.isInTrialPeriod == true } + .map { $0.productId } + return Set(productIdentifiers) + } + + var asDict: [String: Any] { + return [ + "bundleId": bundleId, + "applicationVersion": applicationVersion, + "originalApplicationVersion": originalApplicationVersion, + "opaqueValue": opaqueValue, + "sha1Hash": sha1Hash, + "creationDate": creationDate, + "expirationDate": expirationDate ?? "", + "inAppPurchases": inAppPurchases.map { $0.asDict } + ] + } + + var description: String { + return String(describing: self.asDict) + } +} + diff --git a/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift new file mode 100644 index 0000000000..df580d06be --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/BasicTypes/InAppPurchase.swift @@ -0,0 +1,69 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +// https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html +enum InAppPurchaseAttributeType: Int { + case quantity = 1701, + productId = 1702, + transactionId = 1703, + purchaseDate = 1704, + originalTransactionId = 1705, + originalPurchaseDate = 1706, + productType = 1707, + expiresDate = 1708, + webOrderLineItemId = 1711, + cancellationDate = 1712, + isInTrialPeriod = 1713, + isInIntroOfferPeriod = 1719, + promotionalOfferIdentifier = 1721 +} + +enum InAppPurchaseProductType: Int { + case unknown = -1, + nonConsumable, + consumable, + nonRenewingSubscription, + autoRenewableSubscription +} + +struct InAppPurchase: Equatable { + let quantity: Int + let productId: String + let transactionId: String + let originalTransactionId: String + let productType: InAppPurchaseProductType? + let purchaseDate: Date + let originalPurchaseDate: Date + let expiresDate: Date? + let cancellationDate: Date? + let isInTrialPeriod: Bool? + let isInIntroOfferPeriod: Bool + let webOrderLineItemId: Int + let promotionalOfferIdentifier: String? + + var asDict: [String: Any] { + return [ + "quantity": quantity, + "productId": productId, + "transactionId": transactionId, + "originalTransactionId": originalTransactionId, + "promotionalOfferIdentifier": promotionalOfferIdentifier ?? "", + "purchaseDate": purchaseDate, + "productType": productType?.rawValue ?? "", + "originalPurchaseDate": originalPurchaseDate, + "expiresDate": expiresDate ?? "", + "cancellationDate": cancellationDate ?? "", + "isInTrialPeriod": isInTrialPeriod ?? "", + "isInIntroOfferPeriod": isInIntroOfferPeriod, + "webOrderLineItemId": webOrderLineItemId + ] + } + + var description: String { + return String(describing: self.asDict) + } +} \ No newline at end of file diff --git a/Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift b/Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift new file mode 100644 index 0000000000..fddf2e9565 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilder.swift @@ -0,0 +1,96 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class ASN1ContainerBuilder { + + func build(fromPayload payload: ArraySlice) throws -> ASN1Container { + guard payload.count >= 2, + let firstByte = payload.first else { + throw ReceiptReadingError.asn1ParsingError(description: "payload needs to be at least 2 bytes long") + } + let containerClass = try extractClass(byte: firstByte) + let encodingType = try extractEncodingType(byte: firstByte) + let containerIdentifier = try extractIdentifier(byte: firstByte) + let length = try extractLength(data: payload.dropFirst()) + let bytesUsedForIdentifier = 1 + let bytesUsedForMetadata = bytesUsedForIdentifier + length.bytesUsedForLength + + guard payload.count - bytesUsedForMetadata >= length.value else { + throw ReceiptReadingError.asn1ParsingError(description: "payload is shorter than length value") + } + + let internalPayload = payload.dropFirst(bytesUsedForMetadata).prefix(length.value) + var internalContainers: [ASN1Container] = [] + if encodingType == .constructed { + internalContainers = try buildInternalContainers(payload: internalPayload) + } + return ASN1Container(containerClass: containerClass, + containerIdentifier: containerIdentifier, + encodingType: encodingType, + length: length, + internalPayload: internalPayload, + internalContainers: internalContainers) + } +} + +private extension ASN1ContainerBuilder { + + func buildInternalContainers(payload: ArraySlice) throws -> [ASN1Container] { + var internalContainers = [ASN1Container]() + var currentPayload = payload + while (currentPayload.count > 0) { + let internalContainer = try build(fromPayload: currentPayload) + internalContainers.append(internalContainer) + currentPayload = currentPayload.dropFirst(internalContainer.totalBytesUsed) + } + return internalContainers + } + + func extractClass(byte: UInt8) throws -> ASN1Class { + let firstTwoBits = byte.valueInRange(from: 0, to: 1) + guard let asn1Class = ASN1Class(rawValue: firstTwoBits) else { + throw ReceiptReadingError.asn1ParsingError(description: "couldn't determine asn1 class") + } + return asn1Class + } + + func extractEncodingType(byte: UInt8) throws -> ASN1EncodingType { + let thirdBit = byte.bitAtIndex(2) + guard let encodingType = ASN1EncodingType(rawValue: thirdBit) else { + throw ReceiptReadingError.asn1ParsingError(description: "couldn't determine encoding type") + } + return encodingType + } + + func extractIdentifier(byte: UInt8) throws -> ASN1Identifier { + let lastFiveBits = byte.valueInRange(from: 3, to: 7) + guard let asn1Identifier = ASN1Identifier(rawValue: lastFiveBits) else { + throw ReceiptReadingError.asn1ParsingError(description: "couldn't determine identifier") + } + return asn1Identifier + } + + func extractLength(data: ArraySlice) throws -> ASN1Length { + guard let firstByte = data.first else { + throw ReceiptReadingError.asn1ParsingError(description: "length needs to be at least one byte") + } + + let lengthBit = firstByte.bitAtIndex(0) + let isShortLength = lengthBit == 0 + + let firstByteValue = Int(firstByte.valueInRange(from: 1, to: 7)) + + if isShortLength { + return ASN1Length(value: firstByteValue, bytesUsedForLength: 1) + } else { + let totalLengthOctets = firstByteValue + let lengthBytes = data.dropFirst().prefix(totalLengthOctets) + let lengthValue = lengthBytes.toInt() + return ASN1Length(value: lengthValue, bytesUsedForLength: totalLengthOctets + 1) + } + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift b/Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift new file mode 100644 index 0000000000..322247b484 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilder.swift @@ -0,0 +1,48 @@ +// +// Created by Andrés Boedo on 7/28/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class ASN1ObjectIdentifierBuilder { + + // info on the format: https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-object-identifier + func build(fromPayload payload: ArraySlice) -> ASN1ObjectIdentifier? { + guard let firstByte = payload.first else { return nil } + + var objectIdentifierNumbers: [UInt] = [] + objectIdentifierNumbers.append(UInt(firstByte / 40)) + objectIdentifierNumbers.append(UInt(firstByte % 40)) + + let trailingPayload = payload.dropFirst() + let variableLengthQuantityNumbers = decodeVariableLengthQuantity(payload: trailingPayload) + objectIdentifierNumbers += variableLengthQuantityNumbers + + let objectIdentifierString = objectIdentifierNumbers.map { String($0) } + .joined(separator: ".") + return ASN1ObjectIdentifier(rawValue: objectIdentifierString) + } +} + +private extension ASN1ObjectIdentifierBuilder { + + // https://en.wikipedia.org/wiki/Variable-length_quantity + func decodeVariableLengthQuantity(payload: ArraySlice) -> [UInt] { + var decodedNumbers = [UInt]() + + var currentBuffer: UInt = 0 + var isShortLength = false + for byte in payload { + isShortLength = byte.bitAtIndex(0) == 0 + let byteValue = UInt(byte.valueInRange(from: 1, to: 7)) + + currentBuffer = (currentBuffer << 7) | byteValue + if isShortLength { + decodedNumbers.append(currentBuffer) + currentBuffer = 0 + } + } + return decodedNumbers + } +} \ No newline at end of file diff --git a/Purchases/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift b/Purchases/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift new file mode 100644 index 0000000000..fe135ae682 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilder.swift @@ -0,0 +1,97 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class AppleReceiptBuilder { + private let containerBuilder: ASN1ContainerBuilder + private let inAppPurchaseBuilder: InAppPurchaseBuilder + private let dateFormatter: ISO3601DateFormatter + + private let typeContainerIndex = 0 + private let versionContainerIndex = 1 // unused + private let attributeTypeContainerIndex = 2 + private let expectedInternalContainersCount = 3 // type + version + attribute + + init(containerBuilder: ASN1ContainerBuilder = ASN1ContainerBuilder(), + inAppPurchaseBuilder: InAppPurchaseBuilder = InAppPurchaseBuilder(), + dateFormatter: ISO3601DateFormatter = ISO3601DateFormatter.shared) { + self.containerBuilder = containerBuilder + self.inAppPurchaseBuilder = inAppPurchaseBuilder + self.dateFormatter = dateFormatter + } + + func build(fromContainer container: ASN1Container) throws -> AppleReceipt { + var bundleId: String? + var applicationVersion: String? + var originalApplicationVersion: String? + var opaqueValue: Data? + var sha1Hash: Data? + var creationDate: Date? + var expirationDate: Date? + var inAppPurchases: [InAppPurchase] = [] + + guard let internalContainer = container.internalContainers.first else { + throw ReceiptReadingError.receiptParsingError + } + let receiptContainer = try containerBuilder.build(fromPayload: internalContainer.internalPayload) + for receiptAttribute in receiptContainer.internalContainers { + guard receiptAttribute.internalContainers.count == expectedInternalContainersCount else { + throw ReceiptReadingError.receiptParsingError + } + let typeContainer = receiptAttribute.internalContainers[typeContainerIndex] + let valueContainer = receiptAttribute.internalContainers[attributeTypeContainerIndex] + let attributeType = ReceiptAttributeType(rawValue: typeContainer.internalPayload.toInt()) + guard let nonOptionalType = attributeType else { + continue + } + let payload = valueContainer.internalPayload + + switch nonOptionalType { + case .opaqueValue: + opaqueValue = payload.toData() + case .sha1Hash: + sha1Hash = payload.toData() + case .applicationVersion: + let internalContainer = try containerBuilder.build(fromPayload: payload) + applicationVersion = internalContainer.internalPayload.toString() + case .originalApplicationVersion: + let internalContainer = try containerBuilder.build(fromPayload: payload) + originalApplicationVersion = internalContainer.internalPayload.toString() + case .bundleId: + let internalContainer = try containerBuilder.build(fromPayload: payload) + bundleId = internalContainer.internalPayload.toString() + case .creationDate: + let internalContainer = try containerBuilder.build(fromPayload: payload) + creationDate = internalContainer.internalPayload.toDate(dateFormatter: dateFormatter) + case .expirationDate: + let internalContainer = try containerBuilder.build(fromPayload: payload) + expirationDate = internalContainer.internalPayload.toDate(dateFormatter: dateFormatter) + case .inAppPurchase: + let internalContainer = try containerBuilder.build(fromPayload: payload) + inAppPurchases.append(try inAppPurchaseBuilder.build(fromContainer: internalContainer)) + } + } + + guard let nonOptionalBundleId = bundleId, + let nonOptionalApplicationVersion = applicationVersion, + let nonOptionalOriginalApplicationVersion = originalApplicationVersion, + let nonOptionalOpaqueValue = opaqueValue, + let nonOptionalSha1Hash = sha1Hash, + let nonOptionalCreationDate = creationDate else { + throw ReceiptReadingError.receiptParsingError + } + + let receipt = AppleReceipt(bundleId: nonOptionalBundleId, + applicationVersion: nonOptionalApplicationVersion, + originalApplicationVersion: nonOptionalOriginalApplicationVersion, + opaqueValue: nonOptionalOpaqueValue, + sha1Hash: nonOptionalSha1Hash, + creationDate: nonOptionalCreationDate, + expirationDate: expirationDate, + inAppPurchases: inAppPurchases) + return receipt + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift b/Purchases/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift new file mode 100644 index 0000000000..5a77307880 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilder.swift @@ -0,0 +1,103 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class InAppPurchaseBuilder { + private let containerBuilder: ASN1ContainerBuilder + private let dateFormatter: ISO3601DateFormatter + + private let typeContainerIndex = 0 + private let versionContainerIndex = 1 // unused + private let attributeTypeContainerIndex = 2 + private let expectedInternalContainersCount = 3 // type + version + attribute + + init() { + self.containerBuilder = ASN1ContainerBuilder() + self.dateFormatter = ISO3601DateFormatter.shared + } + + func build(fromContainer container: ASN1Container) throws -> InAppPurchase { + var quantity: Int? + var productId: String? + var transactionId: String? + var originalTransactionId: String? + var productType: InAppPurchaseProductType? + var purchaseDate: Date? + var originalPurchaseDate: Date? + var expiresDate: Date? + var cancellationDate: Date? + var isInTrialPeriod: Bool? + var isInIntroOfferPeriod: Bool? + var webOrderLineItemId: Int? + var promotionalOfferIdentifier: String? + + for internalContainer in container.internalContainers { + guard internalContainer.internalContainers.count == expectedInternalContainersCount else { fatalError() } + let typeContainer = internalContainer.internalContainers[typeContainerIndex] + let valueContainer = internalContainer.internalContainers[attributeTypeContainerIndex] + + guard let attributeType = InAppPurchaseAttributeType(rawValue: typeContainer.internalPayload.toInt()) + else { continue } + + let internalContainer = try containerBuilder.build(fromPayload: valueContainer.internalPayload) + guard internalContainer.length.value > 0 else { continue } + + switch attributeType { + case .quantity: + quantity = internalContainer.internalPayload.toInt() + case .webOrderLineItemId: + webOrderLineItemId = internalContainer.internalPayload.toInt() + case .productType: + productType = InAppPurchaseProductType(rawValue: internalContainer.internalPayload.toInt()) + case .isInIntroOfferPeriod: + isInIntroOfferPeriod = internalContainer.internalPayload.toBool() + case .isInTrialPeriod: + isInTrialPeriod = internalContainer.internalPayload.toBool() + case .productId: + productId = internalContainer.internalPayload.toString() + case .transactionId: + transactionId = internalContainer.internalPayload.toString() + case .originalTransactionId: + originalTransactionId = internalContainer.internalPayload.toString() + case .promotionalOfferIdentifier: + promotionalOfferIdentifier = internalContainer.internalPayload.toString() + case .cancellationDate: + cancellationDate = internalContainer.internalPayload.toDate(dateFormatter: dateFormatter) + case .expiresDate: + expiresDate = internalContainer.internalPayload.toDate(dateFormatter: dateFormatter) + case .originalPurchaseDate: + originalPurchaseDate = internalContainer.internalPayload.toDate(dateFormatter: dateFormatter) + case .purchaseDate: + purchaseDate = internalContainer.internalPayload.toDate(dateFormatter: dateFormatter) + } + } + + guard let nonOptionalQuantity = quantity, + let nonOptionalProductId = productId, + let nonOptionalTransactionId = transactionId, + let nonOptionalOriginalTransactionId = originalTransactionId, + let nonOptionalPurchaseDate = purchaseDate, + let nonOptionalOriginalPurchaseDate = originalPurchaseDate, + let nonOptionalIsInIntroOfferPeriod = isInIntroOfferPeriod, + let nonOptionalWebOrderLineItemId = webOrderLineItemId else { + throw ReceiptReadingError.inAppPurchaseParsingError + } + + return InAppPurchase(quantity: nonOptionalQuantity, + productId: nonOptionalProductId, + transactionId: nonOptionalTransactionId, + originalTransactionId: nonOptionalOriginalTransactionId, + productType: productType, + purchaseDate: nonOptionalPurchaseDate, + originalPurchaseDate: nonOptionalOriginalPurchaseDate, + expiresDate: expiresDate, + cancellationDate: cancellationDate, + isInTrialPeriod: isInTrialPeriod, + isInIntroOfferPeriod: nonOptionalIsInIntroOfferPeriod, + webOrderLineItemId: nonOptionalWebOrderLineItemId, + promotionalOfferIdentifier: promotionalOfferIdentifier) + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+Extensions.swift b/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+Extensions.swift new file mode 100644 index 0000000000..2e041a686b --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+Extensions.swift @@ -0,0 +1,38 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +extension ArraySlice where Element == UInt8 { + func toUInt() -> UInt { + let array = Array(self) + var result: UInt = 0 + for idx in 0..<(array.count) { + let shiftAmount = UInt((array.count) - idx - 1) * 8 + result += UInt(array[idx]) << shiftAmount + } + return result + } + + func toInt() -> Int { + return Int(self.toUInt()) + } + + func toBool() -> Bool { + return self.toUInt() == 1 + } + + func toString() -> String? { + return String(bytes: self, encoding: .utf8) + } + + func toDate(dateFormatter: ISO3601DateFormatter) -> Date? { + return dateFormatter.date(fromBytes: self) + } + + func toData() -> Data { + return Data(self) + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatter.swift b/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatter.swift new file mode 100644 index 0000000000..f683352929 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatter.swift @@ -0,0 +1,23 @@ +// +// Created by Andrés Boedo on 7/29/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +struct ISO3601DateFormatter { + static let shared = ISO3601DateFormatter() + + private let dateFormatter = DateFormatter() + + private init() { + dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ssZ" + } + + func date(fromBytes bytes: ArraySlice) -> Date? { + if let dateString = String(bytes: Array(bytes), encoding: .ascii) { + return dateFormatter.date(from: dateString) + } + return nil + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift b/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift new file mode 100644 index 0000000000..557226c66f --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+Extensions.swift @@ -0,0 +1,45 @@ +// +// UInt8+Extensions.swift +// Purchases +// +// Created by Andrés Boedo on 7/24/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation + +extension UInt8 { + func bitAtIndex(_ index: UInt8) -> UInt8 { + guard index <= 7 else { fatalError("invalid index: \(index)") } + let shifted = self >> (7 - index) + return shifted & 0b1 + } + + func valueInRange(from: UInt8, to: UInt8) -> UInt8 { + guard to <= 7 else { fatalError("invalid index: \(to)") } + guard from <= to else { fatalError("from: \(from) can't be greater than to: \(to)") } + + let range: UInt8 = to - from + 1 + let shifted = self >> (7 - to) + let mask = maskForRange(range) + return shifted & mask + } +} + +private extension UInt8 { + func maskForRange(_ range: UInt8) -> UInt8 { + guard 0 <= range && range <= 8 else { fatalError("range must be between 1 and 8") } + switch range { + case 1: return 0b1 + case 2: return 0b11 + case 3: return 0b111 + case 4: return 0b1111 + case 5: return 0b11111 + case 6: return 0b111111 + case 7: return 0b1111111 + case 8: return 0b11111111 + default: + fatalError("unhandled range") + } + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/ReceiptParser.swift b/Purchases/SwiftSources/LocalReceiptParsing/ReceiptParser.swift new file mode 100644 index 0000000000..5f76271a32 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/ReceiptParser.swift @@ -0,0 +1,58 @@ +// +// ReceiptParser.swift +// Purchases +// +// Created by Andrés Boedo on 7/22/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation + +class ReceiptParser { + private let objectIdentifierParser: ASN1ObjectIdentifierBuilder + private let containerBuilder: ASN1ContainerBuilder + private let receiptBuilder: AppleReceiptBuilder + + init(objectIdentifierParser: ASN1ObjectIdentifierBuilder = ASN1ObjectIdentifierBuilder(), + containerBuilder: ASN1ContainerBuilder = ASN1ContainerBuilder(), + receiptBuilder: AppleReceiptBuilder = AppleReceiptBuilder()) { + self.objectIdentifierParser = objectIdentifierParser + self.containerBuilder = containerBuilder + self.receiptBuilder = receiptBuilder + } + + func parse(from receiptData: Data) throws -> AppleReceipt { + let intData = [UInt8](receiptData) + + let asn1Container = try containerBuilder.build(fromPayload: ArraySlice(intData)) + guard let receiptASN1Container = try findASN1Container(withObjectId: ASN1ObjectIdentifier.data, + inContainer: asn1Container) else { + throw ReceiptReadingError.dataObjectIdentifierMissing + } + let receipt = try receiptBuilder.build(fromContainer: receiptASN1Container) + return receipt + } +} + +private extension ReceiptParser { + func findASN1Container(withObjectId objectId: ASN1ObjectIdentifier, + inContainer container: ASN1Container) throws -> ASN1Container? { + if container.encodingType == .constructed { + for (index, internalContainer) in container.internalContainers.enumerated() { + if internalContainer.containerIdentifier == .objectIdentifier { + let objectIdentifier = objectIdentifierParser.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] + } + } else { + let receipt = try findASN1Container(withObjectId: objectId, inContainer: internalContainer) + if receipt != nil { + return receipt + } + } + } + } + return nil + } +} diff --git a/Purchases/SwiftSources/LocalReceiptParsing/ReceiptParsingError.swift b/Purchases/SwiftSources/LocalReceiptParsing/ReceiptParsingError.swift new file mode 100644 index 0000000000..67116a34b3 --- /dev/null +++ b/Purchases/SwiftSources/LocalReceiptParsing/ReceiptParsingError.swift @@ -0,0 +1,37 @@ +// +// ReceiptParsingError.swift +// Purchases +// +// Created by Andrés Boedo on 7/30/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation + +enum ReceiptReadingError: Error, Equatable { + case missingReceipt, + emptyReceipt, + dataObjectIdentifierMissing, + asn1ParsingError(description: String), + receiptParsingError, + inAppPurchaseParsingError +} + +extension ReceiptReadingError: LocalizedError { + public var errorDescription: String? { + switch self { + case .missingReceipt: + return "The receipt couldn't be found" + case .emptyReceipt: + return "The receipt is empty" + case .dataObjectIdentifierMissing: + return "Couldn't find an object identifier of type data in the receipt" + case .asn1ParsingError(let description): + return "Error while parsing, payload can't be interpreted as ASN1. details: \(description)" + case .receiptParsingError: + return "Error while parsing the receipt. One or more attributes are missing." + case .inAppPurchaseParsingError: + return "Error while parsing in-app purchase. One or more attributes are missing or in the wrong format." + } + } +} diff --git a/Purchases/SwiftSources/Misc/DateExtensions.swift b/Purchases/SwiftSources/Misc/DateExtensions.swift new file mode 100644 index 0000000000..5c07115b96 --- /dev/null +++ b/Purchases/SwiftSources/Misc/DateExtensions.swift @@ -0,0 +1,22 @@ +// +// Created by Andrés Boedo on 8/7/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +extension Date { + + static func from(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) -> Date { + let calendar = Calendar(identifier: .gregorian) + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = day + dateComponents.hour = hour + dateComponents.minute = minute + dateComponents.second = second + guard let date = calendar.date(from: dateComponents) else { fatalError() } + return date + } +} diff --git a/Purchases/SwiftSources/Purchasing/ProductsManager.swift b/Purchases/SwiftSources/Purchasing/ProductsManager.swift new file mode 100644 index 0000000000..295297d22d --- /dev/null +++ b/Purchases/SwiftSources/Purchasing/ProductsManager.swift @@ -0,0 +1,99 @@ +// +// ProductsManager.swift +// Purchases +// +// Created by Andrés Boedo on 7/14/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation +import StoreKit + +internal class ProductsManager: NSObject { + private let productsRequestFactory: ProductsRequestFactory + + private var cachedProductsByIdentifier: [String: SKProduct] = [:] + private let queue = DispatchQueue(label: "ProductsManager") + private var productsByRequests: [SKRequest: Set] = [:] + private var completionHandlers: [Set: [(Set) -> Void]] = [:] + + init(productsRequestFactory: ProductsRequestFactory = ProductsRequestFactory()) { + self.productsRequestFactory = productsRequestFactory + } + + func products(withIdentifiers identifiers: Set, completion: @escaping (Set) -> Void) { + queue.async { [self] in + let productsAlreadyCached = self.cachedProductsByIdentifier.filter { key, _ in identifiers.contains(key) } + if productsAlreadyCached.count == identifiers.count { + let productsAlreadyCachedSet = Set(productsAlreadyCached.values) + NSLog("skipping products request because products were already cached. products: \(identifiers)") + completion(productsAlreadyCachedSet) + return + } + + if let existingHandlers = self.completionHandlers[identifiers] { + NSLog("found an existing request for products: \(identifiers), appending to completion") + self.completionHandlers[identifiers] = existingHandlers + [completion] + return + } + + NSLog("no existing requests and products not cached, starting SKProducts request for: \(identifiers)") + let request = self.productsRequestFactory.request(productIdentifiers: identifiers) + request.delegate = self + self.completionHandlers[identifiers] = [completion] + self.productsByRequests[request] = identifiers + request.start() + } + } +} + +extension ProductsManager: SKProductsRequestDelegate { + + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + queue.async { [self] in + NSLog("products request received response") + guard let requestProducts = self.productsByRequests[request] else { fatalError("couldn't find request") } + guard let completionBlocks = self.completionHandlers[ + requestProducts + ] else { fatalError("couldn't find completion") } + self.completionHandlers.removeValue(forKey: requestProducts) + self.productsByRequests.removeValue(forKey: request) + + self.cacheProducts(response.products) + for completion in completionBlocks { + completion(Set(response.products)) + } + } + } + + func requestDidFinish(_ request: SKRequest) { + NSLog("request did finish") + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + queue.async { [self] in + NSLog("products request failed! error: \(error.localizedDescription)") + guard let products = self.productsByRequests[request] else { fatalError("couldn't find request") } + guard let completionBlocks = self.completionHandlers[products] else { + fatalError("couldn't find completion") + } + + self.completionHandlers.removeValue(forKey: products) + self.productsByRequests.removeValue(forKey: request) + for completion in completionBlocks { + completion(Set()) + } + } + } +} + +private extension ProductsManager { + + func cacheProducts(_ products: [SKProduct]) { + let productsByIdentifier = products.reduce(into: [:]) { resultDict, product in + resultDict[product.productIdentifier] = product + } + + cachedProductsByIdentifier.merge(productsByIdentifier) { (_, new) in new } + } +} diff --git a/Purchases/SwiftSources/Purchasing/ProductsRequestFactory.swift b/Purchases/SwiftSources/Purchasing/ProductsRequestFactory.swift new file mode 100644 index 0000000000..f9562368bc --- /dev/null +++ b/Purchases/SwiftSources/Purchasing/ProductsRequestFactory.swift @@ -0,0 +1,12 @@ +// +// Created by Andrés Boedo on 8/12/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class ProductsRequestFactory { + func request(productIdentifiers: Set) -> SKProductsRequest { + return SKProductsRequest(productIdentifiers: productIdentifiers) + } +} diff --git a/PurchasesCoreSwift/LocalReceiptParsing/LocalReceiptParser.swift b/PurchasesCoreSwift/LocalReceiptParsing/LocalReceiptParser.swift index 0733817503..e69de29bb2 100644 --- a/PurchasesCoreSwift/LocalReceiptParsing/LocalReceiptParser.swift +++ b/PurchasesCoreSwift/LocalReceiptParsing/LocalReceiptParser.swift @@ -1,30 +0,0 @@ -// -// LocalReceiptParser.swift -// Purchases -// -// Created by Andrés Boedo on 6/29/20. -// Copyright © 2020 Purchases. All rights reserved. -// - -import Foundation -@objc internal enum LocalReceiptParserErrorCode: Int { - case ReceiptNotFound, - UnknownError -} - -internal enum IntroEligibilityStatus: Int { - case unknown, - ineligible, - eligible -} - -@objc(RCLocalReceiptParser) public class LocalReceiptParser: NSObject { - - @objc public func checkTrialOrIntroductoryPriceEligibility(withData data: Data, - productIdentifiers: [String], - completion: ([String : Int], Error?) -> Void) { - completion([:], NSError(domain: "This method hasn't been implemented yet", - code: LocalReceiptParserErrorCode.UnknownError.rawValue, - userInfo: nil)) - } -} diff --git a/PurchasesCoreSwiftTests/LocalReceiptParsing/LocalReceiptParserTests.swift b/PurchasesCoreSwiftTests/LocalReceiptParsing/LocalReceiptParserTests.swift index 79d1c7216f..e69de29bb2 100644 --- a/PurchasesCoreSwiftTests/LocalReceiptParsing/LocalReceiptParserTests.swift +++ b/PurchasesCoreSwiftTests/LocalReceiptParsing/LocalReceiptParserTests.swift @@ -1,17 +0,0 @@ -// -// LocalReceiptParserTests.swift -// PurchasesTests -// -// Created by Andrés Boedo on 7/1/20. -// Copyright © 2020 Purchases. All rights reserved. -// - -import Nimble -import XCTest -@testable import PurchasesCoreSwift - -class LocalReceiptParserTests: XCTestCase { - func testCanInitialize() { - expect { LocalReceiptParser() } .notTo(raiseException()) - } -} diff --git a/PurchasesTests/Mocks/MockASN1ContainerBuilder.swift b/PurchasesTests/Mocks/MockASN1ContainerBuilder.swift new file mode 100644 index 0000000000..1e941572e1 --- /dev/null +++ b/PurchasesTests/Mocks/MockASN1ContainerBuilder.swift @@ -0,0 +1,28 @@ +// +// Created by Andrés Boedo on 8/11/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class MockASN1ContainerBuilder: ASN1ContainerBuilder { + + var invokedBuild = false + var invokedBuildCount = 0 + var invokedBuildParameters: (payload: ArraySlice, Void)? + var invokedBuildParametersList = [(payload: ArraySlice < UInt8>, Void)]() + var stubbedBuildError: Error? + var stubbedBuildResult: ASN1Container! + + override func build(fromPayload payload: ArraySlice) throws -> ASN1Container { + invokedBuild = true + invokedBuildCount += 1 + invokedBuildParameters = (payload, ()) + invokedBuildParametersList.append((payload, ())) + if let error = stubbedBuildError { + throw error + } + return stubbedBuildResult + } +} diff --git a/PurchasesTests/Mocks/MockAppleReceiptBuilder.swift b/PurchasesTests/Mocks/MockAppleReceiptBuilder.swift new file mode 100644 index 0000000000..d78c4b80a7 --- /dev/null +++ b/PurchasesTests/Mocks/MockAppleReceiptBuilder.swift @@ -0,0 +1,28 @@ +// +// Created by Andrés Boedo on 8/11/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class MockAppleReceiptBuilder: AppleReceiptBuilder { + + var invokedBuild = false + var invokedBuildCount = 0 + var invokedBuildParameters: ASN1Container? + var invokedBuildParametersList: [ASN1Container] = [] + var stubbedBuildError: Error? + var stubbedBuildResult: AppleReceipt! + + override func build(fromContainer container: ASN1Container) throws -> AppleReceipt { + invokedBuild = true + invokedBuildCount += 1 + invokedBuildParameters = container + invokedBuildParametersList.append(container) + if let error = stubbedBuildError { + throw error + } + return stubbedBuildResult + } +} \ No newline at end of file diff --git a/PurchasesTests/Mocks/MockInAppPurchaseBuilder.swift b/PurchasesTests/Mocks/MockInAppPurchaseBuilder.swift new file mode 100644 index 0000000000..fbccbf86e5 --- /dev/null +++ b/PurchasesTests/Mocks/MockInAppPurchaseBuilder.swift @@ -0,0 +1,28 @@ +// +// Created by Andrés Boedo on 8/10/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class MockInAppPurchaseBuilder: InAppPurchaseBuilder { + + var invokedBuild = false + var invokedBuildCount = 0 + var invokedBuildParameters: (container: ASN1Container, Void)? + var invokedBuildParametersList = [(container: ASN1Container, Void)]() + var stubbedBuildError: Error? + var stubbedBuildResult: InAppPurchase! + + override func build(fromContainer container: ASN1Container) throws -> InAppPurchase { + invokedBuild = true + invokedBuildCount += 1 + invokedBuildParameters = (container, ()) + invokedBuildParametersList.append((container, ())) + if let error = stubbedBuildError { + throw error + } + return stubbedBuildResult + } +} \ No newline at end of file diff --git a/PurchasesTests/Mocks/MockIntroEligibilityCalculator.swift b/PurchasesTests/Mocks/MockIntroEligibilityCalculator.swift new file mode 100644 index 0000000000..1259219236 --- /dev/null +++ b/PurchasesTests/Mocks/MockIntroEligibilityCalculator.swift @@ -0,0 +1,36 @@ +// +// MockIntroEligibilityCalculator.swift +// PurchasesTests +// +// Created by Andrés Boedo on 8/4/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class MockIntroEligibilityCalculator: Purchases.IntroEligibilityCalculator { + + var invokedCheckTrialOrIntroductoryPriceEligibility = false + var invokedCheckTrialOrIntroductoryPriceEligibilityCount = 0 + var invokedCheckTrialOrIntroductoryPriceEligibilityParameters: (receiptData: Data, candidateProductIdentifiers: Set)? + var invokedCheckTrialOrIntroductoryPriceEligibilityParametersList = [(receiptData: Data, + candidateProductIdentifiers: Set)]() + var stubbedCheckTrialOrIntroductoryPriceEligibilityCompletionResult: ([String: NSNumber], Error?)? + + @available(iOS 12.0, macOS 10.14, macCatalyst 13.0, tvOS 12.0, watchOS 6.2, *) + override func checkTrialOrIntroductoryPriceEligibility(with receiptData: Data, + productIdentifiers candidateProductIdentifiers: Set, + completion: @escaping ([String: NSNumber], Error?) -> ()) { + invokedCheckTrialOrIntroductoryPriceEligibility = true + invokedCheckTrialOrIntroductoryPriceEligibilityCount += 1 + invokedCheckTrialOrIntroductoryPriceEligibilityParameters = ( + receiptData, + candidateProductIdentifiers) + invokedCheckTrialOrIntroductoryPriceEligibilityParametersList.append( + (receiptData, candidateProductIdentifiers)) + if let result = stubbedCheckTrialOrIntroductoryPriceEligibilityCompletionResult { + completion(result.0, result.1) + } + } +} diff --git a/PurchasesTests/Mocks/MockProductsManager.swift b/PurchasesTests/Mocks/MockProductsManager.swift new file mode 100644 index 0000000000..856d501cad --- /dev/null +++ b/PurchasesTests/Mocks/MockProductsManager.swift @@ -0,0 +1,27 @@ +// +// Created by Andrés Boedo on 8/11/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +@testable import Purchases + +class MockProductsManager: ProductsManager { + + var invokedProducts = false + var invokedProductsCount = 0 + var invokedProductsParameters: Set? + var invokedProductsParametersList = [Set]() + var stubbedProductsCompletionResult: Set? + + override func products(withIdentifiers identifiers: Set, completion: @escaping (Set) -> Void) { + invokedProducts = true + invokedProductsCount += 1 + invokedProductsParameters = identifiers + invokedProductsParametersList.append(identifiers) + if let result = stubbedProductsCompletionResult { + completion(result) + } + } +} diff --git a/PurchasesTests/Mocks/MockProductsRequest.swift b/PurchasesTests/Mocks/MockProductsRequest.swift new file mode 100644 index 0000000000..bf47f00369 --- /dev/null +++ b/PurchasesTests/Mocks/MockProductsRequest.swift @@ -0,0 +1,48 @@ +// +// Created by Andrés Boedo on 8/12/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +class MockProductResponse: SKProductsResponse { + var mockProducts: [MockSKProduct] + + init(productIdentifiers: Set) { + self.mockProducts = productIdentifiers.map { identifier in + return MockSKProduct(mockProductIdentifier: identifier) + } + super.init() + } + + override var products: [SKProduct] { + return self.mockProducts + } +} + +enum StoreKitError: Error { + case unknown +} + +class MockProductsRequest: SKProductsRequest { + var startCalled = false + var requestedIdentifiers: Set + var fails = false + + override init(productIdentifiers: Set) { + self.requestedIdentifiers = productIdentifiers + super.init() + } + + override func start() { + startCalled = true + DispatchQueue.main.async { + if (self.fails) { + self.delegate?.request!(self, didFailWithError: StoreKitError.unknown) + } else { + let response = MockProductResponse(productIdentifiers: self.requestedIdentifiers) + self.delegate?.productsRequest(self, didReceive: response) + } + } + } +} \ No newline at end of file diff --git a/PurchasesTests/Mocks/MockProductsRequestFactory.swift b/PurchasesTests/Mocks/MockProductsRequestFactory.swift new file mode 100644 index 0000000000..e32a60d985 --- /dev/null +++ b/PurchasesTests/Mocks/MockProductsRequestFactory.swift @@ -0,0 +1,25 @@ +// +// Created by Andrés Boedo on 8/12/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation + +@testable import Purchases + +class MockProductsRequestFactory: ProductsRequestFactory { + + var invokedRequest = false + var invokedRequestCount = 0 + var invokedRequestParameters: Set? + var invokedRequestParametersList = [Set]() + var stubbedRequestResult: MockProductsRequest! + + override func request(productIdentifiers: Set) -> SKProductsRequest { + invokedRequest = true + invokedRequestCount += 1 + invokedRequestParameters = productIdentifiers + invokedRequestParametersList.append(productIdentifiers) + return stubbedRequestResult ?? MockProductsRequest(productIdentifiers: productIdentifiers) + } +} diff --git a/PurchasesTests/Mocks/MockReceiptParser.swift b/PurchasesTests/Mocks/MockReceiptParser.swift new file mode 100644 index 0000000000..c14459ce90 --- /dev/null +++ b/PurchasesTests/Mocks/MockReceiptParser.swift @@ -0,0 +1,35 @@ +// +// Created by Andrés Boedo on 8/11/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class MockReceiptParser: ReceiptParser { + + var invokedParse = false + var invokedParseCount = 0 + var invokedParseParameters: Data? + var invokedParseParametersList = [Data]() + var stubbedParseError: Error? + var stubbedParseResult = AppleReceipt(bundleId: "com.revenuecat.test", + applicationVersion: "5.6.7", + originalApplicationVersion: "3.4.5", + opaqueValue: Data(), + sha1Hash: Data(), + creationDate: Date(), + expirationDate: nil, + inAppPurchases: []) + + override func parse(from receiptData: Data) throws -> AppleReceipt { + invokedParse = true + invokedParseCount += 1 + invokedParseParameters = receiptData + invokedParseParametersList.append(receiptData) + if let error = stubbedParseError { + throw error + } + return stubbedParseResult + } +} \ No newline at end of file diff --git a/PurchasesTests/Mocks/MockSKProduct.swift b/PurchasesTests/Mocks/MockSKProduct.swift index 40648740e1..6fa6604abb 100644 --- a/PurchasesTests/Mocks/MockSKProduct.swift +++ b/PurchasesTests/Mocks/MockSKProduct.swift @@ -6,8 +6,9 @@ class MockSKProduct: SKProduct { var mockProductIdentifier: String - init(mockProductIdentifier: String) { + init(mockProductIdentifier: String, mockSubscriptionGroupIdentifier: String? = nil) { self.mockProductIdentifier = mockProductIdentifier + self.mockSubscriptionGroupIdentifier = mockSubscriptionGroupIdentifier super.init() } diff --git a/PurchasesTests/Purchasing/PurchasesTests.swift b/PurchasesTests/Purchasing/PurchasesTests.swift index f5c1cc74aa..b8fbac75e9 100644 --- a/PurchasesTests/Purchasing/PurchasesTests.swift +++ b/PurchasesTests/Purchasing/PurchasesTests.swift @@ -15,6 +15,7 @@ class PurchasesTests: XCTestCase { requestFetcher = MockRequestFetcher() systemInfo = MockSystemInfo(platformFlavor: nil, platformFlavorVersion: nil, finishTransactions: true) mockOperationDispatcher = MockOperationDispatcher() + mockIntroEligibilityCalculator = MockIntroEligibilityCalculator() } override func tearDown() { @@ -184,6 +185,7 @@ class PurchasesTests: XCTestCase { let identityManager = MockUserManager(mockAppUserID: "app_user"); var systemInfo: MockSystemInfo! var mockOperationDispatcher: MockOperationDispatcher! + var mockIntroEligibilityCalculator: MockIntroEligibilityCalculator! let purchasesDelegate = MockPurchasesDelegate() @@ -206,7 +208,8 @@ class PurchasesTests: XCTestCase { deviceCache: deviceCache, identityManager: identityManager, subscriberAttributesManager: subscriberAttributesManager, - operationDispatcher: mockOperationDispatcher) + operationDispatcher: mockOperationDispatcher, + introEligibilityCalculator: mockIntroEligibilityCalculator) purchases!.delegate = purchasesDelegate Purchases.setDefaultInstance(purchases!) } @@ -228,7 +231,8 @@ class PurchasesTests: XCTestCase { deviceCache: deviceCache, identityManager: identityManager, subscriberAttributesManager: subscriberAttributesManager, - operationDispatcher: mockOperationDispatcher) + operationDispatcher: mockOperationDispatcher, + introEligibilityCalculator: mockIntroEligibilityCalculator) purchases!.delegate = purchasesDelegate } @@ -249,7 +253,8 @@ class PurchasesTests: XCTestCase { deviceCache: deviceCache, identityManager: identityManager, subscriberAttributesManager: subscriberAttributesManager, - operationDispatcher: mockOperationDispatcher) + operationDispatcher: mockOperationDispatcher, + introEligibilityCalculator: mockIntroEligibilityCalculator) purchases!.delegate = purchasesDelegate Purchases.setDefaultInstance(purchases!) diff --git a/PurchasesTests/Purchasing/StoreKitRequestFetcherTests.swift b/PurchasesTests/Purchasing/StoreKitRequestFetcherTests.swift index 98e892b14e..0ce2c1aee5 100644 --- a/PurchasesTests/Purchasing/StoreKitRequestFetcherTests.swift +++ b/PurchasesTests/Purchasing/StoreKitRequestFetcherTests.swift @@ -16,46 +16,6 @@ import Purchases class StoreKitRequestFetcher: XCTestCase { - class MockProductResponse: SKProductsResponse { - var mockProducts: [MockSKProduct] - init(productIdentifiers: Set) { - self.mockProducts = productIdentifiers.map { identifier in - return MockSKProduct(mockProductIdentifier: identifier) - } - super.init() - } - - override var products: [SKProduct] { - return self.mockProducts - } - } - - enum StoreKitError: Error { - case unknown - } - - class MockProductRequest: SKProductsRequest { - var startCalled = false - var requestedIdentifiers: Set - var fails = false - - override init(productIdentifiers: Set) { - self.requestedIdentifiers = productIdentifiers - super.init() - } - - override func start() { - startCalled = true - DispatchQueue.main.async { - if (self.fails) { - self.delegate?.request!(self, didFailWithError: StoreKitError.unknown) - } else { - self.delegate?.productsRequest(self, didReceive: MockProductResponse(productIdentifiers: self.requestedIdentifiers)) - } - } - } - } - class MockReceiptRequest: SKReceiptRefreshRequest { var startCalled = false var fails = false @@ -71,7 +31,6 @@ class StoreKitRequestFetcher: XCTestCase { } } - class MockRequestsFactory: RCProductsRequestFactory { let fails: Bool @@ -81,7 +40,7 @@ class StoreKitRequestFetcher: XCTestCase { var requests: [SKRequest] = [] override func request(forProductIdentifiers identifiers: Set) -> SKProductsRequest { - let r = MockProductRequest(productIdentifiers:identifiers) + let r = MockProductsRequest(productIdentifiers:identifiers) requests.append(r) r.fails = self.fails return r @@ -135,7 +94,7 @@ class StoreKitRequestFetcher: XCTestCase { func testCallsStartOnRequest() { setupFetcher(fails: false) - expect((self.factory!.requests[0] as! MockProductRequest).startCalled).toEventually(beTrue(), timeout: 1.0) + expect((self.factory!.requests[0] as! MockProductsRequest).startCalled).toEventually(beTrue(), timeout: 1.0) } func testReturnsProducts() { diff --git a/PurchasesTests/Resources/receipts/base64encodedreceiptsample1.txt b/PurchasesTests/Resources/receipts/base64encodedreceiptsample1.txt new file mode 100644 index 0000000000..588ea9b55e --- /dev/null +++ b/PurchasesTests/Resources/receipts/base64encodedreceiptsample1.txt @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/PurchasesTests/Resources/receipts/verifyReceiptSample1.txt b/PurchasesTests/Resources/receipts/verifyReceiptSample1.txt new file mode 100644 index 0000000000..eb7ec83fb3 --- /dev/null +++ b/PurchasesTests/Resources/receipts/verifyReceiptSample1.txt @@ -0,0 +1,369 @@ +{ + "status": 0, + "environment": "Sandbox", + "receipt": { + "receipt_type": "ProductionSandbox", + "adam_id": 0, + "app_item_id": 0, + "bundle_id": "com.revenuecat.sampleapp", + "application_version": "4", + "download_id": 0, + "version_external_identifier": 0, + "receipt_creation_date": "2020-07-22 17:39:08 Etc/GMT", + "receipt_creation_date_ms": "1595439548000", + "receipt_creation_date_pst": "2020-07-22 10:39:08 America/Los_Angeles", + "request_date": "2020-07-22 17:54:46 Etc/GMT", + "request_date_ms": "1595440486402", + "request_date_pst": "2020-07-22 10:54:46 America/Los_Angeles", + "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT", + "original_purchase_date_ms": "1375340400000", + "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles", + "original_application_version": "1.0", + "in_app": [ + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692879214", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 19:36:40 Etc/GMT", + "purchase_date_ms": "1594755400000", + "purchase_date_pst": "2020-07-14 12:36:40 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 19:41:40 Etc/GMT", + "expires_date_ms": "1594755700000", + "expires_date_pst": "2020-07-14 12:41:40 America/Los_Angeles", + "web_order_line_item_id": "1000000054042695", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692901513", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:42:57 Etc/GMT", + "purchase_date_ms": "1594762977000", + "purchase_date_pst": "2020-07-14 14:42:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 21:47:57 Etc/GMT", + "expires_date_ms": "1594763277000", + "expires_date_pst": "2020-07-14 14:47:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054042739", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692902182", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:47:57 Etc/GMT", + "purchase_date_ms": "1594763277000", + "purchase_date_pst": "2020-07-14 14:47:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 21:52:57 Etc/GMT", + "expires_date_ms": "1594763577000", + "expires_date_pst": "2020-07-14 14:52:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044460", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692902990", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:52:57 Etc/GMT", + "purchase_date_ms": "1594763577000", + "purchase_date_pst": "2020-07-14 14:52:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 21:57:57 Etc/GMT", + "expires_date_ms": "1594763877000", + "expires_date_pst": "2020-07-14 14:57:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044520", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692905419", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:57:57 Etc/GMT", + "purchase_date_ms": "1594763877000", + "purchase_date_pst": "2020-07-14 14:57:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 22:02:57 Etc/GMT", + "expires_date_ms": "1594764177000", + "expires_date_pst": "2020-07-14 15:02:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044587", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692905971", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 22:02:57 Etc/GMT", + "purchase_date_ms": "1594764177000", + "purchase_date_pst": "2020-07-14 15:02:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 22:07:57 Etc/GMT", + "expires_date_ms": "1594764477000", + "expires_date_pst": "2020-07-14 15:07:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044637", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692906727", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 22:08:20 Etc/GMT", + "purchase_date_ms": "1594764500000", + "purchase_date_pst": "2020-07-14 15:08:20 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 22:13:20 Etc/GMT", + "expires_date_ms": "1594764800000", + "expires_date_pst": "2020-07-14 15:13:20 America/Los_Angeles", + "web_order_line_item_id": "1000000054044710", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.annual_39.99.2_week_intro", + "transaction_id": "1000000696553650", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-22 17:39:06 Etc/GMT", + "purchase_date_ms": "1595439546000", + "purchase_date_pst": "2020-07-22 10:39:06 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-22 18:39:06 Etc/GMT", + "expires_date_ms": "1595443146000", + "expires_date_pst": "2020-07-22 11:39:06 America/Los_Angeles", + "web_order_line_item_id": "1000000054044800", + "is_trial_period": "false", + "is_in_intro_offer_period": "false" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692878476", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 19:33:26 Etc/GMT", + "purchase_date_ms": "1594755206000", + "purchase_date_pst": "2020-07-14 12:33:26 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 19:36:26 Etc/GMT", + "expires_date_ms": "1594755386000", + "expires_date_pst": "2020-07-14 12:36:26 America/Los_Angeles", + "web_order_line_item_id": "1000000054042694", + "is_trial_period": "true", + "is_in_intro_offer_period": "false" + } + ] + }, + "latest_receipt_info": [ + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692878476", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 19:33:26 Etc/GMT", + "purchase_date_ms": "1594755206000", + "purchase_date_pst": "2020-07-14 12:33:26 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 19:36:26 Etc/GMT", + "expires_date_ms": "1594755386000", + "expires_date_pst": "2020-07-14 12:36:26 America/Los_Angeles", + "web_order_line_item_id": "1000000054042694", + "is_trial_period": "true", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692879214", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 19:36:40 Etc/GMT", + "purchase_date_ms": "1594755400000", + "purchase_date_pst": "2020-07-14 12:36:40 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 19:41:40 Etc/GMT", + "expires_date_ms": "1594755700000", + "expires_date_pst": "2020-07-14 12:41:40 America/Los_Angeles", + "web_order_line_item_id": "1000000054042695", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692901513", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:42:57 Etc/GMT", + "purchase_date_ms": "1594762977000", + "purchase_date_pst": "2020-07-14 14:42:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 21:47:57 Etc/GMT", + "expires_date_ms": "1594763277000", + "expires_date_pst": "2020-07-14 14:47:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054042739", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692902182", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:47:57 Etc/GMT", + "purchase_date_ms": "1594763277000", + "purchase_date_pst": "2020-07-14 14:47:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 21:52:57 Etc/GMT", + "expires_date_ms": "1594763577000", + "expires_date_pst": "2020-07-14 14:52:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044460", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692902990", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:52:57 Etc/GMT", + "purchase_date_ms": "1594763577000", + "purchase_date_pst": "2020-07-14 14:52:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 21:57:57 Etc/GMT", + "expires_date_ms": "1594763877000", + "expires_date_pst": "2020-07-14 14:57:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044520", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692905419", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 21:57:57 Etc/GMT", + "purchase_date_ms": "1594763877000", + "purchase_date_pst": "2020-07-14 14:57:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 22:02:57 Etc/GMT", + "expires_date_ms": "1594764177000", + "expires_date_pst": "2020-07-14 15:02:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044587", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692905971", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 22:02:57 Etc/GMT", + "purchase_date_ms": "1594764177000", + "purchase_date_pst": "2020-07-14 15:02:57 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 22:07:57 Etc/GMT", + "expires_date_ms": "1594764477000", + "expires_date_pst": "2020-07-14 15:07:57 America/Los_Angeles", + "web_order_line_item_id": "1000000054044637", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.monthly_4.99.1_week_intro", + "transaction_id": "1000000692906727", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-14 22:08:20 Etc/GMT", + "purchase_date_ms": "1594764500000", + "purchase_date_pst": "2020-07-14 15:08:20 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-14 22:13:20 Etc/GMT", + "expires_date_ms": "1594764800000", + "expires_date_pst": "2020-07-14 15:13:20 America/Los_Angeles", + "web_order_line_item_id": "1000000054044710", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + }, + { + "quantity": "1", + "product_id": "com.revenuecat.annual_39.99.2_week_intro", + "transaction_id": "1000000696553650", + "original_transaction_id": "1000000692878476", + "purchase_date": "2020-07-22 17:39:06 Etc/GMT", + "purchase_date_ms": "1595439546000", + "purchase_date_pst": "2020-07-22 10:39:06 America/Los_Angeles", + "original_purchase_date": "2020-07-14 19:33:27 Etc/GMT", + "original_purchase_date_ms": "1594755207000", + "original_purchase_date_pst": "2020-07-14 12:33:27 America/Los_Angeles", + "expires_date": "2020-07-22 18:39:06 Etc/GMT", + "expires_date_ms": "1595443146000", + "expires_date_pst": "2020-07-22 11:39:06 America/Los_Angeles", + "web_order_line_item_id": "1000000054044800", + "is_trial_period": "false", + "is_in_intro_offer_period": "false", + "subscription_group_identifier": "20662382" + } + ], + "latest_receipt": "...", + "pending_renewal_info": [ + { + "auto_renew_product_id": "com.revenuecat.annual_39.99.2_week_intro", + "original_transaction_id": "1000000692878476", + "product_id": "com.revenuecat.annual_39.99.2_week_intro", + "auto_renew_status": "1" + } + ] +} \ No newline at end of file diff --git a/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift b/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift index 584dceef9e..03f46c9eb6 100644 --- a/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift +++ b/PurchasesTests/SubscriberAttributes/PurchasesSubscriberAttributesTests.swift @@ -6,7 +6,7 @@ import XCTest import Nimble -import Purchases +@testable import Purchases class PurchasesSubscriberAttributesTests: XCTestCase { @@ -29,6 +29,7 @@ class PurchasesSubscriberAttributesTests: XCTestCase { finishTransactions: true) var mockOperationDispatcher: MockOperationDispatcher! + var mockIntroEligibilityCalculator: MockIntroEligibilityCalculator! let purchasesDelegate = MockPurchasesDelegate() @@ -45,6 +46,7 @@ class PurchasesSubscriberAttributesTests: XCTestCase { subscriberAttributeWeight.key: subscriberAttributeWeight ] self.mockOperationDispatcher = MockOperationDispatcher() + self.mockIntroEligibilityCalculator = MockIntroEligibilityCalculator() } override func tearDown() { @@ -70,7 +72,8 @@ class PurchasesSubscriberAttributesTests: XCTestCase { deviceCache: mockDeviceCache, identityManager: mockIdentityManager, subscriberAttributesManager: mockSubscriberAttributesManager, - operationDispatcher: mockOperationDispatcher) + operationDispatcher: mockOperationDispatcher, + introEligibilityCalculator: mockIntroEligibilityCalculator) purchases!.delegate = purchasesDelegate Purchases.setDefaultInstance(purchases!) } diff --git a/PurchasesTests/SwiftSources/IntroEligibilityCalculatorTests.swift b/PurchasesTests/SwiftSources/IntroEligibilityCalculatorTests.swift new file mode 100644 index 0000000000..1a01819957 --- /dev/null +++ b/PurchasesTests/SwiftSources/IntroEligibilityCalculatorTests.swift @@ -0,0 +1,173 @@ +import XCTest +import Nimble + +@testable import Purchases + +@available(iOS 12.0, macOS 10.14, macCatalyst 13.0, tvOS 12.0, watchOS 6.2, *) +class IntroEligibilityCalculatorTests: XCTestCase { + + var calculator: IntroEligibilityCalculator! + let mockProductsManager = MockProductsManager() + let mockReceiptParser = MockReceiptParser() + + override func setUp() { + super.setUp() + calculator = IntroEligibilityCalculator(productsManager: mockProductsManager, + receiptParser: mockReceiptParser) + } + + func testCheckTrialOrIntroductoryPriceEligibilityReturnsEmptyIfNoProductIds() { + var receivedError: Error? = nil + var receivedEligibility: [String: NSNumber]? = nil + var completionCalled = false + calculator.checkTrialOrIntroductoryPriceEligibility(with: Data(), + productIdentifiers: Set()) { eligibilityByProductId, + error in + completionCalled = true + receivedError = error + receivedEligibility = eligibilityByProductId + } + + expect(completionCalled).toEventually(beTrue()) + expect(receivedError).to(beNil()) + expect(receivedEligibility).toNot(beNil()) + expect(receivedEligibility).to(beEmpty()) + } + + func testCheckTrialOrIntroductoryPriceEligibilityReturnsErrorIfReceiptParserThrows() { + var receivedError: Error? = nil + var receivedEligibility: [String: NSNumber]? = nil + var completionCalled = false + let productIdentifiers = Set(["com.revenuecat.test"]) + + mockReceiptParser.stubbedParseError = ReceiptReadingError.receiptParsingError + + calculator.checkTrialOrIntroductoryPriceEligibility(with: Data(), + productIdentifiers: productIdentifiers) { + eligibilityByProductId, + error in + completionCalled = true + receivedError = error + receivedEligibility = eligibilityByProductId + } + + expect(completionCalled).toEventually(beTrue()) + expect(receivedError).to(matchError(ReceiptReadingError.receiptParsingError)) + expect(receivedEligibility).toNot(beNil()) + expect(receivedEligibility).to(beEmpty()) + } + + func testCheckTrialOrIntroductoryPriceEligibilityMakesOnlyOneProductsRequest() { + var completionCalled = false + + let receipt = mockReceipt() + mockReceiptParser.stubbedParseResult = receipt + let receiptIdentifiers = receipt.purchasedIntroOfferOrFreeTrialProductIdentifiers() + + mockProductsManager.stubbedProductsCompletionResult = Set(["a", "b"].map { + MockSKProduct(mockProductIdentifier: $0) + }) + + let candidateIdentifiers = Set(["a", "b", "c"]) + calculator.checkTrialOrIntroductoryPriceEligibility(with: Data(), + productIdentifiers: Set(candidateIdentifiers)) { _, _ in + completionCalled = true + } + + expect(completionCalled).toEventually(beTrue()) + expect(self.mockProductsManager.invokedProductsCount) == 1 + expect(self.mockProductsManager.invokedProductsParameters) == candidateIdentifiers.union(receiptIdentifiers) + } + + func testCheckTrialOrIntroductoryPriceEligibilityGetsCorrectResult() { + var receivedError: Error? = nil + var receivedEligibility: [String: NSNumber]? = nil + var completionCalled = false + + let receipt = mockReceipt() + mockReceiptParser.stubbedParseResult = receipt + mockProductsManager.stubbedProductsCompletionResult = Set([ + MockSKProduct(mockProductIdentifier: "com.revenuecat.product1", + mockSubscriptionGroupIdentifier: "group1"), + MockSKProduct(mockProductIdentifier: "com.revenuecat.product2", + mockSubscriptionGroupIdentifier: "group2") + ]) + + let candidateIdentifiers = Set(["com.revenuecat.product1", + "com.revenuecat.product2", + "com.revenuecat.unknownProduct"]) + + calculator.checkTrialOrIntroductoryPriceEligibility(with: Data(), + productIdentifiers: Set(candidateIdentifiers)) { eligibility, error in + completionCalled = true + receivedError = error + receivedEligibility = eligibility + } + + expect(completionCalled).toEventually(beTrue()) + expect(receivedError).to(beNil()) + expect(receivedEligibility) == [ + "com.revenuecat.product1": IntroEligibilityStatus.eligible.toNSNumber(), + "com.revenuecat.product2": IntroEligibilityStatus.ineligible.toNSNumber(), + "com.revenuecat.unknownProduct": IntroEligibilityStatus.unknown.toNSNumber(), + ] + } +} + +@available(iOS 12.0, macOS 10.14, macCatalyst 13.0, tvOS 12.0, watchOS 6.2, *) +private extension IntroEligibilityCalculatorTests { + func mockInAppPurchases() -> [InAppPurchase] { + return [ + InAppPurchase(quantity: 1, + productId: "com.revenuecat.product1", + transactionId: "65465265651323", + originalTransactionId: "65465265651323", + productType: .consumable, + purchaseDate: Date(), + originalPurchaseDate: Date(), + expiresDate: nil, + cancellationDate: nil, + isInTrialPeriod: false, + isInIntroOfferPeriod: false, + webOrderLineItemId: 516854313, + promotionalOfferIdentifier: nil), + InAppPurchase(quantity: 1, + productId: "com.revenuecat.product2", + transactionId: "65465265651322", + originalTransactionId: "65465265651321", + productType: .autoRenewableSubscription, + purchaseDate: Date(), + originalPurchaseDate: Date(), + expiresDate: Date(), + cancellationDate: nil, + isInTrialPeriod: false, + isInIntroOfferPeriod: false, + webOrderLineItemId: 64651321, + promotionalOfferIdentifier: nil), + InAppPurchase(quantity: 1, + productId: "com.revenuecat.product2", + transactionId: "65465265651321", + originalTransactionId: "65465265651321", + productType: .autoRenewableSubscription, + purchaseDate: Date(), + originalPurchaseDate: Date(), + expiresDate: Date(), + cancellationDate: nil, + isInTrialPeriod: true, + isInIntroOfferPeriod: false, + webOrderLineItemId: 64651320, + promotionalOfferIdentifier: nil) + ] + } + + func mockReceipt() -> AppleReceipt { + return AppleReceipt(bundleId: "com.revenuecat.test", + applicationVersion: "3.4.5", + originalApplicationVersion: "3.2.1", + opaqueValue: Data(), + sha1Hash: Data(), + creationDate: Date(), + expirationDate: nil, + inAppPurchases: mockInAppPurchases()) + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilderTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilderTests.swift new file mode 100644 index 0000000000..2e98aa45f9 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ContainerBuilderTests.swift @@ -0,0 +1,207 @@ +import XCTest +import Nimble + +@testable import Purchases + +class ASN1ContainerBuilderTests: XCTestCase { + var containerBuilder: ASN1ContainerBuilder! + let mockContainerPayload: [UInt8] = [0b01, 0b01, 0b01, 0b01, 0b01, 0b01, 0b01, 0b01, 0b01] + + override func setUp() { + super.setUp() + containerBuilder = ASN1ContainerBuilder() + } + + func testBuildFromContainerExtractsClassCorrectly() { + let universalClassByte: UInt8 = 0b00000000 + var payloadArray = mockContainerPayload + payloadArray.insert(universalClassByte, at: 0) + var payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).containerClass) == .universal + + let applicationClassByte: UInt8 = 0b01000000 + payloadArray = mockContainerPayload + payloadArray.insert(applicationClassByte, at: 0) + payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).containerClass) == .application + + let contextSpecificClassByte: UInt8 = 0b10000000 + payloadArray = mockContainerPayload + payloadArray.insert(contextSpecificClassByte, at: 0) + payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).containerClass) == .contextSpecific + + let privateClassByte: UInt8 = 0b11000000 + payloadArray = mockContainerPayload + payloadArray.insert(privateClassByte, at: 0) + payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).containerClass) == .private + } + + func testBuildFromContainerExtractsEncodingTypeCorrectly() { + let primitiveEncodingByte: UInt8 = 0b00000000 + var payloadArray = mockContainerPayload + payloadArray.insert(primitiveEncodingByte, at: 0) + var payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).encodingType) == .primitive + + let constructedEncodingByte: UInt8 = 0b00100000 + payloadArray = mockContainerPayload + payloadArray.insert(constructedEncodingByte, at: 0) + + let containerLenghtByte: UInt8 = UInt8(3) + payloadArray.insert(containerLenghtByte, at: 1) + + payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).encodingType) == .constructed + } + + func testBuildFromContainerExtractsIdentifierCorrectly() { + for expectedIdentifier in ASN1Identifier.allCases { + let identifierByte = UInt8(expectedIdentifier.rawValue) + + var payloadArray = mockContainerPayload + payloadArray.insert(identifierByte, at: 0) + let payload = ArraySlice(payloadArray) + try! expect(self.containerBuilder.build(fromPayload: payload).containerIdentifier) == expectedIdentifier + } + } + + func testBuildFromContainerExtractsShortLengthCorrectly() { + let shortLengthValue: UInt8 = UInt8(mockContainerPayload.count - 1) + + var payloadArray = mockContainerPayload + payloadArray.insert(shortLengthValue, at: 1) + let payload = ArraySlice(payloadArray) + + let container = try! self.containerBuilder.build(fromPayload: payload) + expect(container.length.value) == Int(shortLengthValue) + expect(container.length.bytesUsedForLength) == 1 + expect(container.internalPayload.count) == Int(shortLengthValue) + expect(container.internalPayload) == payload.dropFirst(2).prefix(Int(shortLengthValue)) + } + + func testBuildFromContainerExtractsLongLengthCorrectly() { + // first 1 indicates long length, the next 7 bits are the number of bytes used for length + let totalLengthBytes: [UInt8] = [0b10000011] + + // bytes after the first indicate the actual length value. + let lengthBytes: [UInt8] = [0b1, 0b1, 0b11] + let expectedLengthValue = ArraySlice(lengthBytes).toInt() + + let lengthArray = totalLengthBytes + lengthBytes + + var payloadArray: [UInt8] = Array(repeating: 0, count: 100000) + payloadArray.insert(contentsOf: lengthArray, at: 1) + let payload = ArraySlice(payloadArray) + + let container = try! self.containerBuilder.build(fromPayload: payload) + + expect(container.length.value) == expectedLengthValue + expect(container.length.bytesUsedForLength) == 4 + expect(container.internalPayload.count) == Int(expectedLengthValue) + + expect(container.internalPayload) == payload.dropFirst(lengthArray.count + 1).prefix(Int(expectedLengthValue)) + } + + func testBuildFromContainerRaisesIfPayloadSizeSmallerThanLengthWithShortLength() { + let shortLengthValue: UInt8 = 55 + + var payloadArray = mockContainerPayload + payloadArray.insert(shortLengthValue, at: 1) + let payload = ArraySlice(payloadArray) + expect { try self.containerBuilder.build(fromPayload: payload).length.value }.to(throwError()) + } + + func testBuildFromContainerRaisesIfPayloadSizeSmallerThanLengthWithLongLength() { + // first 1 indicates long length, the next 7 bits are the number of bytes used for length + let totalLengthBytes: [UInt8] = [0b10000011] + + // bytes after the first indicate the actual length value. + let lengthBytes: [UInt8] = [0b1, 0b1, 0b11] + + let lengthArray = totalLengthBytes + lengthBytes + + var payloadArray: [UInt8] = [0b1, 0b1] + payloadArray.insert(contentsOf: lengthArray, at: 1) + let payload = ArraySlice(payloadArray) + + expect { try self.containerBuilder.build(fromPayload: payload).length.value }.to(throwError()) + } + + func testBuildFromContainerCalculatesTotalBytesCorrectlyForShortLength() { + let lengthByte: UInt8 = 0b00000111 + + var payloadArray: [UInt8] = Array(repeating: 0, count: 100) + payloadArray.insert(lengthByte, at: 1) + let payload = ArraySlice(payloadArray) + + let container = try! self.containerBuilder.build(fromPayload: payload) + + expect(container.totalBytesUsed) == 1 + 1 + container.internalPayload.count + expect(container.internalPayload.count) == Int(container.length.value) + } + + func testBuildFromContainerCalculatesTotalBytesCorrectlyForLongLength() { + // first 1 indicates long length, the next 7 bits are the number of bytes used for length + let totalLengthBytes: [UInt8] = [0b10000011] + + let lengthBytes: [UInt8] = [0b1, 0b1, 0b11] + + let lengthArray = totalLengthBytes + lengthBytes + + var payloadArray: [UInt8] = Array(repeating: 0, count: 100000) + payloadArray.insert(contentsOf: lengthArray, at: 1) + let payload = ArraySlice(payloadArray) + + let container = try! self.containerBuilder.build(fromPayload: payload) + + expect(container.totalBytesUsed) == 1 + lengthArray.count + container.internalPayload.count + } + + func testBuildFromContainerThatIsTooSmallThrows() { + expect { try self.containerBuilder.build(fromPayload: ArraySlice([0b1])) }.to(throwError()) + } + + func testBuildFromContainerBuildsInternalContainersCorrectlyIfTypeIsConstructed() { + let constructedEncodingByte: UInt8 = 0b00100000 + + let subContainer1InternalPayload = Array(repeating: UInt8(0b1), count: 4) + let subContainer2InternalPayload = Array(repeating: UInt8(0b1), count: 6) + let subContainer1Payload: [UInt8] = [UInt8(0b1), + UInt8(UInt8(subContainer1InternalPayload.count))] + + subContainer1InternalPayload + let subContainer2Payload: [UInt8] = [UInt8(0b1), + UInt8(UInt8(subContainer2InternalPayload.count))] + + subContainer2InternalPayload + + let containerPayload: [UInt8] = [constructedEncodingByte, // id byte + UInt8(subContainer1Payload.count + subContainer2Payload.count)] // length byte + + subContainer1Payload + subContainer2Payload // payload + + let payload = ArraySlice(containerPayload) + let container = try! self.containerBuilder.build(fromPayload: payload) + + expect(container.internalContainers.count) == 2 + } + + func testBuildFromContainerDoesntBuildInternalContainersIfTypeIsPrimitive() { + let primitiveEncodingByte: UInt8 = 0b00000000 + var payloadArray = mockContainerPayload + payloadArray.insert(primitiveEncodingByte, at: 0) + let payload = ArraySlice(payloadArray) + + let container = try! self.containerBuilder.build(fromPayload: payload) + expect(container.encodingType) == .primitive + expect(container.internalContainers).to(beEmpty()) + } + + func testBuildFromContainerRaisesIfTypeIsConstructedButContainerCantBeBuiltFromPayload() { + let constructedEncodingByte: UInt8 = 0b00100000 + var payloadArray = mockContainerPayload + payloadArray.insert(constructedEncodingByte, at: 0) + let payload = ArraySlice(payloadArray) + + expect { try self.containerBuilder.build(fromPayload: payload) }.to(throwError()) + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilderTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilderTests.swift new file mode 100644 index 0000000000..2304325b3c --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/ASN1ObjectIdentifierBuilderTests.swift @@ -0,0 +1,49 @@ +import XCTest +import Nimble + +@testable import Purchases + +class ASN1ObjectIdentifierBuilderTests: XCTestCase { + + let encoder = ASN1ObjectIdentifierEncoder() + func testBuildFromPayloadBuildsCorrectlyForDataPayload() { + let payload = encoder.objectIdentifierPayload(.data) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)) == .data + } + + func testBuildFromPayloadBuildsCorrectlyForSignedDataPayload() { + let payload = encoder.objectIdentifierPayload(.signedData) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)) == .signedData + } + + func testBuildFromPayloadBuildsCorrectlyForEnvelopedDataPayload() { + let payload = encoder.objectIdentifierPayload(.envelopedData) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)) == .envelopedData + } + + func testBuildFromPayloadBuildsCorrectlyForSignedAndEnvelopedDataPayload() { + let payload = encoder.objectIdentifierPayload(.signedAndEnvelopedData) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)) == .signedAndEnvelopedData + } + + func testBuildFromPayloadBuildsCorrectlyForDigestedDataPayload() { + let payload = encoder.objectIdentifierPayload(.digestedData) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)) == .digestedData + } + + func testBuildFromPayloadBuildsCorrectlyForEncryptedDataPayload() { + let payload = encoder.objectIdentifierPayload(.encryptedData) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)) == .encryptedData + } + + func testBuildFromPayloadReturnsNilIfIdentifierNotRecognized() { + let unknownObjectID = [1, 3, 23, 534643, 7454, 1, 7, 2] + let payload = encoder.encodeASN1ObjectIdentifier(numbers: unknownObjectID) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)).to(beNil()) + } + + func testBuildFromPayloadReturnsNilIfIdentifierPayloadEmpty() { + let payload: ArraySlice = ArraySlice([]) + expect(ASN1ObjectIdentifierBuilder().build(fromPayload: payload)).to(beNil()) + } +} \ No newline at end of file diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilderTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilderTests.swift new file mode 100644 index 0000000000..3158ed95e8 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/AppleReceiptBuilderTests.swift @@ -0,0 +1,221 @@ +import XCTest +import Nimble + +@testable import Purchases + +class AppleReceiptBuilderTests: XCTestCase { + let containerFactory = ContainerFactory() + var appleReceiptBuilder: AppleReceiptBuilder! + var mockInAppPurchaseBuilder: MockInAppPurchaseBuilder! + + let bundleId = "com.revenuecat.test" + let applicationVersion = "3.2.1" + let originalApplicationVersion = "1.2.2" + let creationDate = Date.from(year: 2020, month: 3, day: 23, hour: 15, minute: 5, second: 3) + + override func setUp() { + super.setUp() + self.mockInAppPurchaseBuilder = MockInAppPurchaseBuilder() + self.appleReceiptBuilder = AppleReceiptBuilder(inAppPurchaseBuilder: mockInAppPurchaseBuilder) + } + + func testCanBuildCorrectlyWithMinimalAttributes() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + expect { try self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) }.notTo(throwError()) + } + + func testBuildGetsCorrectBundleId() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + let receipt = try! self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) + expect(receipt.bundleId) == bundleId + } + + func testBuildGetsCorrectApplicationVersion() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + let receipt = try! self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) + expect(receipt.applicationVersion) == applicationVersion + } + + func testBuildGetsCorrectOriginalApplicationVersion() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + let receipt = try! self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) + expect(receipt.originalApplicationVersion) == originalApplicationVersion + } + + func testBuildGetsCorrectCreationDate() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + let receipt = try! self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) + expect(receipt.creationDate) == creationDate + } + + func testBuildGetsSha1Hash() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + let receipt = try! self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) + expect(receipt.sha1Hash).toNot(beNil()) + } + + func testBuildGetsOpaqueValue() { + let sampleReceiptContainer = sampleReceiptContainerWithMinimalAttributes() + let receipt = try! self.appleReceiptBuilder.build(fromContainer: sampleReceiptContainer) + expect(receipt.opaqueValue).toNot(beNil()) + } + + func testBuildGetsExpiresDate() { + let expirationDate = Date.from(year: 2020, month: 7, day: 4, hour: 5, minute: 3, second: 2) + let expirationDateContainer = containerFactory + .receiptAttributeContainer(attributeType: ReceiptAttributeType.expirationDate, + expirationDate) + + let receiptContainer = containerFactory + .receiptContainerFromContainers(containers: minimalAttributes() + [expirationDateContainer]) + let receipt = try! self.appleReceiptBuilder.build(fromContainer: receiptContainer) + expect(receipt.expirationDate) == expirationDate + } + + func testBuildGetsInAppPurchases() { + let totalInAppPurchases = Int.random(in: 5..<20) + let inAppContainers = (Int(0).. [ASN1Container] { + return [ + bundleIdContainer(), + appVersionContainer(), + originalAppVersionContainer(), + opaqueValueContainer(), + sha1HashContainer(), + creationDateContainer() + ] + } + + func sampleReceiptContainerWithMinimalAttributes() -> ASN1Container { + return containerFactory.receiptContainerFromContainers(containers: minimalAttributes()) + } +} + +private extension AppleReceiptBuilderTests { + + func creationDateContainer() -> ASN1Container { + containerFactory.receiptAttributeContainer(attributeType: ReceiptAttributeType.creationDate, + creationDate) + } + + func sha1HashContainer() -> ASN1Container { + containerFactory.receiptDataAttributeContainer(attributeType: ReceiptAttributeType.sha1Hash) + } + + func opaqueValueContainer() -> ASN1Container { + containerFactory.receiptDataAttributeContainer(attributeType: ReceiptAttributeType.opaqueValue) + } + + func originalAppVersionContainer() -> ASN1Container { + containerFactory + .receiptAttributeContainer(attributeType: ReceiptAttributeType.originalApplicationVersion, + originalApplicationVersion) + } + + func appVersionContainer() -> ASN1Container { + containerFactory.receiptAttributeContainer(attributeType: ReceiptAttributeType.applicationVersion, + applicationVersion) + } + + func bundleIdContainer() -> ASN1Container { + containerFactory.receiptAttributeContainer(attributeType: ReceiptAttributeType.bundleId, + bundleId) + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilderTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilderTests.swift new file mode 100644 index 0000000000..27871e0a13 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/Builders/InAppPurchaseBuilderTests.swift @@ -0,0 +1,316 @@ +// +// Created by Andrés Boedo on 8/7/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +import XCTest +import Nimble + +@testable import Purchases + +class InAppPurchaseBuilderTests: XCTestCase { + let quantity = 2 + let productId = "com.revenuecat.sampleProduct" + let transactionId = "089230953203" + let originalTransactionId = "089230953101" + let productType = InAppPurchaseProductType.autoRenewableSubscription + let purchaseDate = Date.from(year: 2019, month: 5, day: 3, hour: 1, minute: 55, second: 1) + let originalPurchaseDate = Date.from(year: 2018, month: 6, day: 22, hour: 1, minute: 55, second: 1) + let expiresDate = Date.from(year: 2018, month: 6, day: 22, hour: 1, minute: 55, second: 1) + let cancellationDate = Date.from(year: 2019, month: 7, day: 4, hour: 7, minute: 1, second: 45) + let isInTrialPeriod = false + let isInIntroOfferPeriod = true + let webOrderLineItemId = 897501072 + let promotionalOfferIdentifier = "com.revenuecat.productPromoOffer" + + private let containerFactory = ContainerFactory() + private var inAppPurchaseBuilder: InAppPurchaseBuilder! + + override func setUp() { + super.setUp() + self.inAppPurchaseBuilder = InAppPurchaseBuilder() + } + + func testCanBuildFromMinimalAttributes() { + let sampleReceiptContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + expect { try self.inAppPurchaseBuilder.build(fromContainer: sampleReceiptContainer) }.notTo(throwError()) + } + + func testBuildGetsCorrectQuantity() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.quantity) == quantity + } + + func testBuildGetsCorrectProductId() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.productId) == productId + } + + func testBuildGetsCorrectTransactionId() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.transactionId) == transactionId + } + + func testBuildGetsCorrectOriginalTransactionId() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.originalTransactionId) == originalTransactionId + } + + func testBuildGetsCorrectPurchaseDate() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.purchaseDate) == purchaseDate + } + + func testBuildGetsCorrectOriginalPurchaseDate() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.originalPurchaseDate) == originalPurchaseDate + } + + func testBuildGetsCorrectIsInIntroOfferPeriod() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.isInIntroOfferPeriod) == isInIntroOfferPeriod + } + + func testBuildGetsCorrectWebOrderLineItemId() { + let sampleInAppPurchaseContainer = sampleInAppPurchaseContainerWithMinimalAttributes() + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: sampleInAppPurchaseContainer) + expect(inAppPurchase.webOrderLineItemId) == webOrderLineItemId + } + + func testBuildGetsCorrectProductType() { + let inAppPurchaseContainer = containerFactory + .inAppPurchaseContainerFromContainers(containers: minimalAttributes() + [productTypeContainer()]) + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) + + expect(inAppPurchase.productType) == productType + } + + func testBuildGetsCorrectExpiresDate() { + let inAppPurchaseContainer = containerFactory + .inAppPurchaseContainerFromContainers(containers: minimalAttributes() + [expiresDateContainer()]) + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) + + expect(inAppPurchase.expiresDate) == expiresDate + } + + func testBuildGetsCorrectCancellationDate() { + let inAppPurchaseContainer = containerFactory + .inAppPurchaseContainerFromContainers(containers: minimalAttributes() + [cancellationDateContainer()]) + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) + + expect(inAppPurchase.cancellationDate) == cancellationDate + } + + func testBuildGetsCorrectIsInTrialPeriod() { + let inAppPurchaseContainer = containerFactory + .inAppPurchaseContainerFromContainers(containers: minimalAttributes() + [isInTrialPeriodContainer()]) + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) + + expect(inAppPurchase.isInTrialPeriod) == isInTrialPeriod + } + + func testBuildGetsCorrectPromotionalOfferIdentifier() { + let inAppPurchaseContainer = containerFactory + .inAppPurchaseContainerFromContainers(containers: minimalAttributes() + [promotionalOfferIdentifierContainer()]) + let inAppPurchase = try! self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) + + expect(inAppPurchase.promotionalOfferIdentifier) == promotionalOfferIdentifier + } + + func testBuildThrowsIfQuantityIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + productIdContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfProductIdIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfTransactionIdIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + productIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfOriginalTransactionIdIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + productIdContainer(), + transactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfPurchaseDateIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + productIdContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfOriginalPurchaseDateIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + productIdContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfIsInIntroOfferPeriodIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + productIdContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + webOrderLineItemIdContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } + + func testBuildThrowsIfWebOrderLineItemIdIsMissing() { + let inAppPurchaseContainer = containerFactory.inAppPurchaseContainerFromContainers(containers: [ + quantityContainer(), + productIdContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer() + ]) + expect { try self.inAppPurchaseBuilder.build(fromContainer: inAppPurchaseContainer) }.to(throwError()) + } +} + +private extension InAppPurchaseBuilderTests { + + func sampleInAppPurchaseContainerWithMinimalAttributes() -> ASN1Container { + return containerFactory.inAppPurchaseContainerFromContainers(containers: minimalAttributes()) + } + + func minimalAttributes() -> [ASN1Container] { + return [ + quantityContainer(), + productIdContainer(), + transactionIdContainer(), + originalTransactionIdContainer(), + purchaseDateContainer(), + originalPurchaseDateContainer(), + isInIntroOfferPeriodContainer(), + webOrderLineItemIdContainer() + ] + } + + func quantityContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.quantity, + quantity) + } + + func productIdContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.productId, + productId) + } + + func transactionIdContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.transactionId, + transactionId) + } + + func originalTransactionIdContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.originalTransactionId, + originalTransactionId) + } + + func productTypeContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.productType, + productType.rawValue) + } + + func purchaseDateContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.purchaseDate, + purchaseDate) + } + + func originalPurchaseDateContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.originalPurchaseDate, + originalPurchaseDate) + } + + func expiresDateContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.expiresDate, + expiresDate) + } + + func cancellationDateContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.cancellationDate, + cancellationDate) + } + + func isInTrialPeriodContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.isInTrialPeriod, + isInTrialPeriod) + } + + func isInIntroOfferPeriodContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.isInIntroOfferPeriod, + isInIntroOfferPeriod) + } + + func webOrderLineItemIdContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.webOrderLineItemId, + webOrderLineItemId) + } + + func promotionalOfferIdentifierContainer() -> ASN1Container { + return containerFactory.receiptAttributeContainer(attributeType: InAppPurchaseAttributeType.promotionalOfferIdentifier, + promotionalOfferIdentifier) + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+ExtensionsTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+ExtensionsTests.swift new file mode 100644 index 0000000000..56b51c4065 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ArraySlice+ExtensionsTests.swift @@ -0,0 +1,17 @@ +import XCTest +import Nimble + +@testable import Purchases + +class ArraySliceUInt8ExtensionsTests: XCTestCase { + func testToUIntReturnsCorrectValue() { + var arraySlice = ArraySlice([UInt8(0b10000000), UInt8(0b10000000)]) + expect(arraySlice.toUInt()) == 0b10000000_10000000 + + arraySlice = ArraySlice([UInt8(0b1), UInt8(0b01), UInt8(0b10)]) + expect(arraySlice.toUInt()) == 0b00000001_00000001_00000010 + + arraySlice = ArraySlice([UInt8(0b10010100)]) + expect(arraySlice.toUInt()) == 0b10010100 + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatterTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatterTests.swift new file mode 100644 index 0000000000..ab4f64c933 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/ISO3601DateFormatterTests.swift @@ -0,0 +1,30 @@ +import XCTest +import Nimble + +@testable import Purchases + +class ISO3601DateFormatterTests: XCTestCase { + + func testDateFromBytesReturnsCorrectValueIfPossible() { + let timeZone = TimeZone(identifier: "UTC") + let dateComponents = DateComponents(timeZone: timeZone, + year: 2020, + month: 7, + day: 14, + hour: 19, + minute: 36, + second: 40) + let date = Calendar.current.date(from: dateComponents) + guard let dateBytes = "2020-07-14T19:36:40+0000".data(using: .ascii) else { fatalError() } + expect(ISO3601DateFormatter.shared.date(fromBytes: ArraySlice(dateBytes))) == date + } + + func testDateFromBytesReturnsNilIfItCantBeParsedAsString() { + expect(ISO3601DateFormatter.shared.date(fromBytes: ArraySlice([0b11]))).to(beNil()) + } + + func testDateFromBytesReturnsNilIfItCantBeParsedIntoDate() { + guard let stringAsBytes = "some string that isn't a date".data(using: .ascii) else { fatalError() } + expect(ISO3601DateFormatter.shared.date(fromBytes: ArraySlice(stringAsBytes))).to(beNil()) + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+ExtensionsTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+ExtensionsTests.swift new file mode 100644 index 0000000000..dfed35fa9c --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/DataConverters/UInt8+ExtensionsTests.swift @@ -0,0 +1,38 @@ +import XCTest +import Nimble + +@testable import Purchases + +class UInt8ExtensionsTests: XCTestCase { + + func testBitAtIndexGetsCorrectValue() { + expect(UInt8(0b10000000).bitAtIndex(0)) == 1 + expect(UInt8(0b10000000).bitAtIndex(1)) == 0 + expect(UInt8(0b00100000).bitAtIndex(2)) == 1 + expect(UInt8(0b11111111).bitAtIndex(3)) == 1 + expect(UInt8(0b01100000).bitAtIndex(4)) == 0 + expect(UInt8(0b10001111).bitAtIndex(5)) == 1 + expect(UInt8(0b10000000).bitAtIndex(6)) == 0 + } + + func testBitAtIndexRaisesIfInvalidIndex() { + expect { _ = UInt8(0b1).bitAtIndex(7) }.notTo(throwAssertion()) + expect { _ = UInt8(0b1).bitAtIndex(8) }.to(throwAssertion()) + } + + func testValueInRangeGetsCorrectValue() { + expect(UInt8(0b10000000).valueInRange(from: 0, to: 1)) == 0b10 + expect(UInt8(0b10000000).valueInRange(from: 0, to: 4)) == 0b10000 + expect(UInt8(0b00100000).valueInRange(from: 1, to: 7)) == 0b0100000 + expect(UInt8(0b11111111).valueInRange(from: 3, to: 5)) == 0b111 + expect(UInt8(0b01100000).valueInRange(from: 5, to: 7)) == 0b000 + expect(UInt8(0b10001111).valueInRange(from: 2, to: 5)) == 0b0011 + expect(UInt8(0b10000010).valueInRange(from: 6, to: 6)) == 0b1 + } + + func testValueInRangeRaisesIfInvalidRange() { + expect{ _ = UInt8(0b10000010).valueInRange(from: 1, to: 6)}.notTo(throwAssertion()) + expect{ _ = UInt8(0b10000010).valueInRange(from: 6, to: 1)}.to(throwAssertion()) + expect{ _ = UInt8(0b10000010).valueInRange(from: 6, to: 8)}.to(throwAssertion()) + } +} diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/ReceiptParserTests.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/ReceiptParserTests.swift new file mode 100644 index 0000000000..2e64771187 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/ReceiptParserTests.swift @@ -0,0 +1,131 @@ +import XCTest +import Nimble + +@testable import Purchases + +class ReceiptParserTests: XCTestCase { + var receiptParser: ReceiptParser! + var mockAppleReceiptBuilder: MockAppleReceiptBuilder! + var mockASN1ContainerBuilder: MockASN1ContainerBuilder! + + private let containerFactory = ContainerFactory() + + override func setUp() { + super.setUp() + mockAppleReceiptBuilder = MockAppleReceiptBuilder() + mockASN1ContainerBuilder = MockASN1ContainerBuilder() + receiptParser = ReceiptParser(containerBuilder: mockASN1ContainerBuilder, + receiptBuilder: mockAppleReceiptBuilder) + } + + func testParseFromReceiptDataBuildsContainerAfterObjectIdentifier() { + let receiptContainer = containerFactory.receiptContainerFromContainers(containers: []) + let dataObjectIdentifierContainer = containerFactory.objectIdentifierContainer(.data) + let constructedContainer = containerFactory.constructedContainer(containers: [ + dataObjectIdentifierContainer, + receiptContainer + ]) + + mockASN1ContainerBuilder.stubbedBuildResult = constructedContainer + let expectedReceipt = mockAppleReceipt() + mockAppleReceiptBuilder.stubbedBuildResult = expectedReceipt + + let receivedReceipt = try! self.receiptParser.parse(from: Data()) + + expect(self.mockAppleReceiptBuilder.invokedBuildCount) == 1 + expect(self.mockAppleReceiptBuilder.invokedBuildParameters) == receiptContainer + expect(receivedReceipt) == expectedReceipt + } + + func testParseFromReceiptDataBuildsContainerAfterObjectIdentifierInComplexContainer() { + let receiptContainer = containerFactory.receiptContainerFromContainers(containers: []) + let dataObjectIdentifierContainer = containerFactory.objectIdentifierContainer(.data) + + let complexContainer = containerFactory.constructedContainer(containers: [ + containerFactory.simpleDataContainer(), + containerFactory.objectIdentifierContainer(.signedData), + containerFactory.constructedContainer(containers: [ + containerFactory.simpleDataContainer(), + containerFactory.intContainer(int: 656), + ]), + containerFactory.simpleDataContainer(), + containerFactory.stringContainer(string: "some string"), + containerFactory.constructedContainer(containers: [ + containerFactory.simpleDataContainer(), + containerFactory.intContainer(int: 656), + containerFactory.constructedContainer(containers: [ + dataObjectIdentifierContainer, + receiptContainer, + ]), + containerFactory.dateContainer(date: Date()), + ]), + containerFactory.objectIdentifierContainer(.encryptedData), + ]) + + mockASN1ContainerBuilder.stubbedBuildResult = complexContainer + let expectedReceipt = mockAppleReceipt() + mockAppleReceiptBuilder.stubbedBuildResult = expectedReceipt + + let receivedReceipt = try! self.receiptParser.parse(from: Data()) + + expect(self.mockAppleReceiptBuilder.invokedBuildCount) == 1 + expect(self.mockAppleReceiptBuilder.invokedBuildParameters) == receiptContainer + expect(receivedReceipt) == expectedReceipt + } + + func testParseFromReceiptThrowsIfReceiptBuilderThrows() { + let container = containerWithDataObjectIdentifier() + + mockASN1ContainerBuilder.stubbedBuildResult = container + mockAppleReceiptBuilder.stubbedBuildError = ReceiptReadingError.receiptParsingError + + expect { try self.receiptParser.parse(from: Data()) }.to(throwError(ReceiptReadingError.receiptParsingError)) + } + + func testParseFromReceiptThrowsIfNoDataObjectIdentifierFound() { + let container = containerFactory.constructedContainer(containers: [ + containerFactory.objectIdentifierContainer(.signedAndEnvelopedData), + containerFactory.receiptContainerFromContainers(containers: []) + ]) + + mockASN1ContainerBuilder.stubbedBuildResult = container + + expect { try self.receiptParser.parse(from: Data()) } + .to(throwError(ReceiptReadingError.dataObjectIdentifierMissing)) + } + + func testParseFromReceiptThrowsIfReceiptPayloadIsntLocatedAfterDataObjectIdentifierContainer() { + let container = containerFactory.constructedContainer(containers: [ + containerFactory.receiptContainerFromContainers(containers: []), + containerFactory.objectIdentifierContainer(.data), + ]) + + mockASN1ContainerBuilder.stubbedBuildResult = container + + expect { try self.receiptParser.parse(from: Data()) } + .to(throwError(ReceiptReadingError.dataObjectIdentifierMissing)) + } +} + +private extension ReceiptParserTests { + func containerWithDataObjectIdentifier() -> ASN1Container { + let receiptContainer = containerFactory.receiptContainerFromContainers(containers: []) + let dataObjectIdentifierContainer = containerFactory.objectIdentifierContainer(.data) + let constructedContainer = containerFactory.constructedContainer(containers: [ + dataObjectIdentifierContainer, + receiptContainer + ]) + return constructedContainer + } + + func mockAppleReceipt() -> AppleReceipt { + return AppleReceipt(bundleId: "com.revenuecat.testapp", + applicationVersion: "3.2.3", + originalApplicationVersion: "3.1.1", + opaqueValue: Data(), + sha1Hash: Data(), + creationDate: Date(), + expirationDate: nil, + inAppPurchases: []) + } +} \ No newline at end of file diff --git a/PurchasesTests/SwiftSources/LocalReceiptParsing/TestsAgainstRealReceipts/ReceiptParsing+TestsWithRealReceipts.swift b/PurchasesTests/SwiftSources/LocalReceiptParsing/TestsAgainstRealReceipts/ReceiptParsing+TestsWithRealReceipts.swift new file mode 100644 index 0000000000..5d5219d454 --- /dev/null +++ b/PurchasesTests/SwiftSources/LocalReceiptParsing/TestsAgainstRealReceipts/ReceiptParsing+TestsWithRealReceipts.swift @@ -0,0 +1,193 @@ +// +// ReceiptParsing+TestsWithRealReceipts.swift +// PurchasesTests +// +// Created by Andrés Boedo on 8/6/20. +// Copyright © 2020 Purchases. All rights reserved. +// + +import Foundation +import XCTest +import Nimble + +@testable import Purchases + +class ReceiptParsingRealReceiptTests: XCTestCase { + + let receipt1Name = "base64encodedreceiptsample1" + + func testBasicReceiptAttributesForSample1() { + let receiptData = sampleReceiptData(receiptName: receipt1Name) + let receipt = try! ReceiptParser().parse(from: receiptData) + + expect(receipt.applicationVersion) == "4" + expect(receipt.bundleId) == "com.revenuecat.sampleapp" + expect(receipt.originalApplicationVersion) == "1.0" + expect(receipt.creationDate) == Date(timeIntervalSince1970: 1595439548) + expect(receipt.expirationDate).to(beNil()) + } + + func testInAppPurchasesAttributesForSample1() { + let receiptData = sampleReceiptData(receiptName: receipt1Name) + let receipt = try! ReceiptParser().parse(from: receiptData) + let inAppPurchases = receipt.inAppPurchases + + expect(inAppPurchases.count) == 9 + + let inAppPurchase0 = inAppPurchases[0] + expect(inAppPurchase0.quantity) == 1 + expect(inAppPurchase0.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase0.transactionId) == "1000000692879214" + expect(inAppPurchase0.originalTransactionId) == "1000000692878476" + expect(inAppPurchase0.productType) == .autoRenewableSubscription + expect(inAppPurchase0.purchaseDate) == Date(timeIntervalSince1970: 1594755400) + expect(inAppPurchase0.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase0.expiresDate) == Date(timeIntervalSince1970: 1594755700) + expect(inAppPurchase0.cancellationDate).to(beNil()) + expect(inAppPurchase0.isInTrialPeriod) == false + expect(inAppPurchase0.isInIntroOfferPeriod) == false + expect(inAppPurchase0.webOrderLineItemId) == 1000000054042695 + expect(inAppPurchase0.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase1 = inAppPurchases[1] + expect(inAppPurchase1.quantity) == 1 + expect(inAppPurchase1.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase1.transactionId) == "1000000692901513" + expect(inAppPurchase1.originalTransactionId) == "1000000692878476" + expect(inAppPurchase1.productType) == .autoRenewableSubscription + expect(inAppPurchase1.purchaseDate) == Date(timeIntervalSince1970: 1594762977) + expect(inAppPurchase1.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase1.expiresDate) == Date(timeIntervalSince1970: 1594763277) + expect(inAppPurchase1.cancellationDate).to(beNil()) + expect(inAppPurchase1.isInTrialPeriod) == false + expect(inAppPurchase1.isInIntroOfferPeriod) == false + expect(inAppPurchase1.webOrderLineItemId) == 1000000054042739 + expect(inAppPurchase1.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase2 = inAppPurchases[2] + expect(inAppPurchase2.quantity) == 1 + expect(inAppPurchase2.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase2.transactionId) == "1000000692902182" + expect(inAppPurchase2.originalTransactionId) == "1000000692878476" + expect(inAppPurchase2.productType) == .autoRenewableSubscription + expect(inAppPurchase2.purchaseDate) == Date(timeIntervalSince1970: 1594763277) + expect(inAppPurchase2.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase2.expiresDate) == Date(timeIntervalSince1970: 1594763577) + expect(inAppPurchase2.cancellationDate).to(beNil()) + expect(inAppPurchase2.isInTrialPeriod) == false + expect(inAppPurchase2.isInIntroOfferPeriod) == false + expect(inAppPurchase2.webOrderLineItemId) == 1000000054044460 + expect(inAppPurchase2.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase3 = inAppPurchases[3] + expect(inAppPurchase3.quantity) == 1 + expect(inAppPurchase3.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase3.transactionId) == "1000000692902990" + expect(inAppPurchase3.originalTransactionId) == "1000000692878476" + expect(inAppPurchase3.productType) == .autoRenewableSubscription + expect(inAppPurchase3.purchaseDate) == Date(timeIntervalSince1970: 1594763577) + expect(inAppPurchase3.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase3.expiresDate) == Date(timeIntervalSince1970: 1594763877) + expect(inAppPurchase3.cancellationDate).to(beNil()) + expect(inAppPurchase3.isInTrialPeriod) == false + expect(inAppPurchase3.isInIntroOfferPeriod) == false + expect(inAppPurchase3.webOrderLineItemId) == 1000000054044520 + expect(inAppPurchase3.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase4 = inAppPurchases[4] + expect(inAppPurchase4.quantity) == 1 + expect(inAppPurchase4.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase4.transactionId) == "1000000692905419" + expect(inAppPurchase4.originalTransactionId) == "1000000692878476" + expect(inAppPurchase4.productType) == .autoRenewableSubscription + expect(inAppPurchase4.purchaseDate) == Date(timeIntervalSince1970: 1594763877) + expect(inAppPurchase4.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase4.expiresDate) == Date(timeIntervalSince1970: 1594764177) + expect(inAppPurchase4.cancellationDate).to(beNil()) + expect(inAppPurchase4.isInTrialPeriod) == false + expect(inAppPurchase4.isInIntroOfferPeriod) == false + expect(inAppPurchase4.webOrderLineItemId) == 1000000054044587 + expect(inAppPurchase4.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase5 = inAppPurchases[5] + expect(inAppPurchase5.quantity) == 1 + expect(inAppPurchase5.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase5.transactionId) == "1000000692905971" + expect(inAppPurchase5.originalTransactionId) == "1000000692878476" + expect(inAppPurchase5.productType) == .autoRenewableSubscription + expect(inAppPurchase5.purchaseDate) == Date(timeIntervalSince1970: 1594764177) + expect(inAppPurchase5.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase5.expiresDate) == Date(timeIntervalSince1970: 1594764477) + expect(inAppPurchase5.cancellationDate).to(beNil()) + expect(inAppPurchase5.isInTrialPeriod) == false + expect(inAppPurchase5.isInIntroOfferPeriod) == false + expect(inAppPurchase5.webOrderLineItemId) == 1000000054044637 + expect(inAppPurchase5.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase6 = inAppPurchases[6] + expect(inAppPurchase6.quantity) == 1 + expect(inAppPurchase6.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase6.transactionId) == "1000000692906727" + expect(inAppPurchase6.originalTransactionId) == "1000000692878476" + expect(inAppPurchase6.productType) == .autoRenewableSubscription + expect(inAppPurchase6.purchaseDate) == Date(timeIntervalSince1970: 1594764500) + expect(inAppPurchase6.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase6.expiresDate) == Date(timeIntervalSince1970: 1594764800) + expect(inAppPurchase6.cancellationDate).to(beNil()) + expect(inAppPurchase6.isInTrialPeriod) == false + expect(inAppPurchase6.isInIntroOfferPeriod) == false + expect(inAppPurchase6.webOrderLineItemId) == 1000000054044710 + expect(inAppPurchase6.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase7 = inAppPurchases[7] + expect(inAppPurchase7.quantity) == 1 + expect(inAppPurchase7.productId) == "com.revenuecat.annual_39.99.2_week_intro" + expect(inAppPurchase7.transactionId) == "1000000696553650" + expect(inAppPurchase7.originalTransactionId) == "1000000692878476" + expect(inAppPurchase7.productType) == .autoRenewableSubscription + expect(inAppPurchase7.purchaseDate) == Date(timeIntervalSince1970: 1595439546) + expect(inAppPurchase7.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase7.expiresDate) == Date(timeIntervalSince1970: 1595443146) + expect(inAppPurchase7.cancellationDate).to(beNil()) + expect(inAppPurchase7.isInTrialPeriod) == false + expect(inAppPurchase7.isInIntroOfferPeriod) == false + expect(inAppPurchase7.webOrderLineItemId) == 1000000054044800 + expect(inAppPurchase7.promotionalOfferIdentifier).to(beNil()) + + let inAppPurchase8 = inAppPurchases[8] + expect(inAppPurchase8.quantity) == 1 + expect(inAppPurchase8.productId) == "com.revenuecat.monthly_4.99.1_week_intro" + expect(inAppPurchase8.transactionId) == "1000000692878476" + expect(inAppPurchase8.originalTransactionId) == "1000000692878476" + expect(inAppPurchase8.productType) == .autoRenewableSubscription + expect(inAppPurchase8.purchaseDate) == Date(timeIntervalSince1970: 1594755206) + expect(inAppPurchase8.originalPurchaseDate) == Date(timeIntervalSince1970: 1594755207) + expect(inAppPurchase8.expiresDate) == Date(timeIntervalSince1970: 1594755386) + expect(inAppPurchase8.cancellationDate).to(beNil()) + expect(inAppPurchase8.isInTrialPeriod) == true + expect(inAppPurchase8.isInIntroOfferPeriod) == false + expect(inAppPurchase8.webOrderLineItemId) == 1000000054042694 + expect(inAppPurchase8.promotionalOfferIdentifier).to(beNil()) + } +} + +private extension ReceiptParsingRealReceiptTests { + + func sampleReceiptData(receiptName: String) -> Data { + let receiptText = readFile(named: receiptName) + guard let receiptData = Data(base64Encoded: receiptText) else { fatalError("couldn't decode file") } + return receiptData + } + + func readFile(named filename: String) -> String { + guard let pathString = Bundle(for: type(of: self)).path(forResource: filename, ofType: "txt") else { + fatalError("\(filename) not found") + } + do { + return try String(contentsOfFile: pathString, encoding: String.Encoding.utf8) + } + catch let error { + fatalError("couldn't read file named \(filename). Error: \(error.localizedDescription)") + } + } +} diff --git a/PurchasesTests/SwiftSources/Purchasing/ProductsManagerTests.swift b/PurchasesTests/SwiftSources/Purchasing/ProductsManagerTests.swift new file mode 100644 index 0000000000..b2f819b7e5 --- /dev/null +++ b/PurchasesTests/SwiftSources/Purchasing/ProductsManagerTests.swift @@ -0,0 +1,93 @@ +import XCTest +import Nimble + +@testable import Purchases + +class ProductsManagerTests: XCTestCase { + var productsRequestFactory: MockProductsRequestFactory! + var productsManager: ProductsManager! + + override func setUp() { + super.setUp() + productsRequestFactory = MockProductsRequestFactory() + productsManager = ProductsManager(productsRequestFactory: productsRequestFactory) + } + + func testProductsWithIdentifiersMakesRightRequest() { + let productIdentifiers = Set(["1", "2", "3"]) + productsManager.products(withIdentifiers: productIdentifiers) { _ in } + expect(self.productsRequestFactory.invokedRequestCount).toEventually(equal(1)) + expect(self.productsRequestFactory.invokedRequestParameters) == productIdentifiers + } + + func testProductsWithIdentifiersCallsCompletionCorrectly() { + let productIdentifiers = Set(["1", "2", "3"]) + var receivedProducts: Set? + var completionCalled = false + + productsManager.products(withIdentifiers: productIdentifiers) { products in + completionCalled = true + receivedProducts = products + } + + expect(completionCalled).toEventually(beTrue()) + expect(receivedProducts?.count) == productIdentifiers.count + let receivedProductsSet = Set(receivedProducts!.map { $0.productIdentifier }) + expect(receivedProductsSet) == productIdentifiers + } + + func testProductsWithIdentifiersReturnsFromCacheIfProductsAlreadyCached() { + let productIdentifiers = Set(["1", "2", "3"]) + var completionCallCount = 0 + + productsManager.products(withIdentifiers: productIdentifiers) { products in + completionCallCount += 1 + + self.productsManager.products(withIdentifiers: productIdentifiers) { products in + completionCallCount += 1 + } + } + + expect(completionCallCount).toEventually(equal(2)) + expect(self.productsRequestFactory.invokedRequestCount).toEventually(equal(1)) + expect(self.productsRequestFactory.invokedRequestParameters) == productIdentifiers + } + + func testProductsWithIdentifiersReturnsDoesntMakeNewRequestIfProductsAreBeingFetched() { + let productIdentifiers = Set(["1", "2", "3"]) + + productsManager.products(withIdentifiers: productIdentifiers) { _ in } + productsManager.products(withIdentifiers: productIdentifiers) { _ in } + + expect(self.productsRequestFactory.invokedRequestCount).toEventually(equal(1)) + expect(self.productsRequestFactory.invokedRequestParameters) == productIdentifiers + } + + func testProductsWithIdentifiersMakesNewRequestIfAtLeastOneNewProductRequested() { + let firstCallProducts = Set(["1", "2", "3"]) + let secondCallProducts = Set(["1", "2", "3", "4"]) + productsManager.products(withIdentifiers: firstCallProducts) { _ in } + productsManager.products(withIdentifiers: secondCallProducts) { _ in } + + expect(self.productsRequestFactory.invokedRequestCount).toEventually(equal(2)) + expect(self.productsRequestFactory.invokedRequestParametersList) == [firstCallProducts, secondCallProducts] + } + + func testProductsWithIdentifiersReturnsErrorAndEmptySetIfRequestFails() { + let productIdentifiers = Set(["1", "2", "3"]) + + let failingRequest = MockProductsRequest(productIdentifiers: productIdentifiers) + failingRequest.fails = true + productsRequestFactory.stubbedRequestResult = failingRequest + + var receivedProducts: Set? + var completionCalled = false + + productsManager.products(withIdentifiers: productIdentifiers) { products in + completionCalled = true + receivedProducts = products + } + expect(completionCalled).toEventually(beTrue()) + expect(receivedProducts).to(beEmpty()) + } +} \ No newline at end of file diff --git a/PurchasesTests/TestHelpers/ASN1ObjectIdentifierEncoder.swift b/PurchasesTests/TestHelpers/ASN1ObjectIdentifierEncoder.swift new file mode 100644 index 0000000000..f3783ea089 --- /dev/null +++ b/PurchasesTests/TestHelpers/ASN1ObjectIdentifierEncoder.swift @@ -0,0 +1,65 @@ +// +// Created by Andrés Boedo on 8/11/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class ASN1ObjectIdentifierEncoder { + func objectIdentifierPayload(_ objectIdentifier: ASN1ObjectIdentifier) -> ArraySlice { + return encodeASN1ObjectIdentifier(numbers: objectIdentifierNumbers(objectIdentifier)) + } + + func encodeASN1ObjectIdentifier(numbers: [Int]) -> ArraySlice { + // https://docs.microsoft.com/en-us/windows/win32/seccertenroll/about-object-identifier + + var encodedNumbers: [UInt8] = [] + + let firstValue = numbers[0] + let secondValue = numbers[1] + encodedNumbers.append(UInt8(firstValue * 40 + secondValue)) + for number in numbers.dropFirst(2) { + if number < 127 { + encodedNumbers.append(UInt8(number)) + } else { + let numberAsBytes = encodeLongNumber(number: number) + encodedNumbers.append(contentsOf: numberAsBytes) + } + } + + return ArraySlice(encodedNumbers) + } +} + +private extension ASN1ObjectIdentifierEncoder { + + func objectIdentifierNumbers(_ objectIdentifier: ASN1ObjectIdentifier) -> [Int] { + return objectIdentifier.rawValue.split(separator: ".").map { Int($0)! } + } + + func encodeLongNumber(number: Int) -> [UInt8] { + let numberAsBinaryString = String(number, radix: 2) + let numberAsListOfBinaryStrings = splitStringIntoGroups(ofLength: 7, string: numberAsBinaryString) + let bytes = numberAsListOfBinaryStrings.map { UInt8($0, radix: 2)! } + let encodedBytes = listByAddingOneToTheFirstBitOfAllButLast(numbers: bytes) + return encodedBytes + } + + func splitStringIntoGroups(ofLength length: Int, string: String) -> [String] { + guard length > 0 else { return [] } + + let totalGroups: Int = (string.count + length - 1) / length + let range = 0.. [UInt8] { + guard numbers.count > 0, let lastNumber = numbers.last else { return [] } + return numbers.dropLast().map { $0 | (1 << 7) } + [lastNumber] + } +} \ No newline at end of file diff --git a/PurchasesTests/TestHelpers/ContainerFactory.swift b/PurchasesTests/TestHelpers/ContainerFactory.swift new file mode 100644 index 0000000000..ec5d2fca9d --- /dev/null +++ b/PurchasesTests/TestHelpers/ContainerFactory.swift @@ -0,0 +1,177 @@ +// +// Created by Andrés Boedo on 8/6/20. +// Copyright (c) 2020 Purchases. All rights reserved. +// + +import Foundation +@testable import Purchases + +class ContainerFactory { + private let objectIdentifierEncoder = ASN1ObjectIdentifierEncoder() + + func simpleDataContainer() -> ASN1Container { + let length = 55 + return ASN1Container(containerClass: .application, + containerIdentifier: .octetString, + encodingType: .primitive, + length: ASN1Length(value: length, bytesUsedForLength: 1), + internalPayload: ArraySlice(Array(repeating: UInt8(0b1), count: length)), + internalContainers: []) + } + + func stringContainer(string: String) -> ASN1Container { + let stringAsBytes = string.utf8 + guard stringAsBytes.count < 128 else { fatalError("this method is intended for short strings only") } + return ASN1Container(containerClass: .application, + containerIdentifier: .octetString, + encodingType: .primitive, + length: ASN1Length(value: stringAsBytes.count, bytesUsedForLength: 1), + internalPayload: ArraySlice(Array(stringAsBytes)), + internalContainers: []) + } + + func dateContainer(date: Date) -> ASN1Container { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ssZ" + + let dateString = dateFormatter.string(from: date) + guard let stringAsData = (dateString.data(using: .ascii)) else { fatalError() } + let stringAsBytes = [UInt8](stringAsData) + guard stringAsBytes.count < 128 else { fatalError("this method is intended for short strings only") } + + return ASN1Container(containerClass: .application, + containerIdentifier: .octetString, + encodingType: .primitive, + length: ASN1Length(value: stringAsBytes.count, bytesUsedForLength: 1), + internalPayload: ArraySlice(stringAsBytes), + internalContainers: []) + } + + func boolContainer(bool: Bool) -> ASN1Container { + return ASN1Container(containerClass: .application, + containerIdentifier: .octetString, + encodingType: .primitive, + length: ASN1Length(value: 1, bytesUsedForLength: 1), + internalPayload: ArraySlice([UInt8(booleanLiteral: bool)]), + internalContainers: []) + } + + func intContainer(int: Int) -> ASN1Container { + let intAsBytes = intToBytes(int: int) + let bytesUsedForLength = intAsBytes.count < 128 ? 1 : intToBytes(int: intAsBytes.count).count + 1 + + return ASN1Container(containerClass: .application, + containerIdentifier: .octetString, + encodingType: .primitive, + length: ASN1Length(value: intAsBytes.count, bytesUsedForLength: bytesUsedForLength), + internalPayload: ArraySlice(intAsBytes), + internalContainers: []) + } + + func constructedContainer(containers: [ASN1Container], + encodingType: ASN1EncodingType = .constructed) -> ASN1Container { + let payload = containers.flatMap { self.headerBytes(forContainer: $0) + $0.internalPayload } + let bytesUsedForLength = payload.count < 128 ? 1 : intToBytes(int: payload.count).count + 1 + return ASN1Container(containerClass: .application, + containerIdentifier: .octetString, + encodingType: encodingType, + length: ASN1Length(value: payload.count, bytesUsedForLength: bytesUsedForLength), + internalPayload: ArraySlice(payload), + internalContainers: containers) + } + + func receiptDataAttributeContainer(attributeType: BuildableReceiptAttributeType) -> ASN1Container { + let typeContainer = intContainer(int: attributeType.rawValue) + let versionContainer = intContainer(int: 1) + let valueContainer = simpleDataContainer() + + return constructedContainer(containers: [typeContainer, versionContainer, valueContainer]) + } + + func receiptAttributeContainer(attributeType: BuildableReceiptAttributeType, _ value: Int) -> ASN1Container { + let typeContainer = intContainer(int: attributeType.rawValue) + let versionContainer = intContainer(int: 1) + let valueContainer = constructedContainer(containers: [intContainer(int: value)]) + + return constructedContainer(containers: [typeContainer, versionContainer, valueContainer]) + } + + func receiptAttributeContainer(attributeType: BuildableReceiptAttributeType, _ date: Date) -> ASN1Container { + let typeContainer = intContainer(int: attributeType.rawValue) + let versionContainer = intContainer(int: 1) + let valueContainer = constructedContainer(containers: [dateContainer(date: date)]) + + return constructedContainer(containers: [typeContainer, versionContainer, valueContainer]) + } + + func receiptAttributeContainer(attributeType: BuildableReceiptAttributeType, _ bool: Bool) -> ASN1Container { + let typeContainer = intContainer(int: attributeType.rawValue) + let versionContainer = intContainer(int: 1) + let valueContainer = constructedContainer(containers: [boolContainer(bool: bool)]) + + return constructedContainer(containers: [typeContainer, versionContainer, valueContainer]) + } + + func receiptAttributeContainer(attributeType: BuildableReceiptAttributeType, + _ string: String) -> ASN1Container { + let typeContainer = intContainer(int: attributeType.rawValue) + let versionContainer = intContainer(int: 1) + let valueContainer = constructedContainer(containers: [stringContainer(string: string)]) + + return constructedContainer(containers: [typeContainer, versionContainer, valueContainer]) + } + + func receiptContainerFromContainers(containers: [ASN1Container]) -> ASN1Container { + let attributesContainer = constructedContainer(containers: containers) + + let receiptWrapper = constructedContainer(containers: [attributesContainer], + encodingType: .primitive) + return constructedContainer(containers: [receiptWrapper], + encodingType: .constructed) + } + + func inAppPurchaseContainerFromContainers(containers: [ASN1Container]) -> ASN1Container { + return constructedContainer(containers: containers, + encodingType: .constructed) + } + + func objectIdentifierContainer(_ objectIdentifier: ASN1ObjectIdentifier) -> ASN1Container { + let payload = objectIdentifierEncoder.objectIdentifierPayload(objectIdentifier) + let bytesUsedForLength = payload.count < 128 ? 1 : intToBytes(int: payload.count).count + 1 + + return ASN1Container(containerClass: .application, + containerIdentifier: .objectIdentifier, + encodingType: .primitive, + length: ASN1Length(value: payload.count, bytesUsedForLength: bytesUsedForLength), + internalPayload: payload, + internalContainers: []) + } +} + +private extension ContainerFactory { + func intToBytes(int: Int) -> [UInt8] { + let intAsBytes = withUnsafeBytes(of: int.bigEndian, Array.init) + let arrayWithoutInsignificantBytes = Array(intAsBytes.drop(while: { $0 == 0 })) + return arrayWithoutInsignificantBytes + } + + func headerBytes(forContainer container: ASN1Container) -> [UInt8] { + let identifierHeader = (container.containerClass.rawValue << 6 + | container.encodingType.rawValue << 5 + | container.containerIdentifier.rawValue) + if container.length.value < 128 { + return [identifierHeader] + [UInt8(container.length.value)] + } else { + var lengthHeader = intToBytes(int: container.length.value) + let firstByte = 0b10000000 | UInt8(container.length.bytesUsedForLength - 1) + lengthHeader.insert(firstByte, at: 0) + return [identifierHeader] + lengthHeader + } + } +} + +protocol BuildableReceiptAttributeType { + var rawValue: Int { get } +} +extension InAppPurchaseAttributeType: BuildableReceiptAttributeType {} +extension ReceiptAttributeType: BuildableReceiptAttributeType {} \ No newline at end of file