diff --git a/Qonversion.xcodeproj/project.pbxproj b/Qonversion.xcodeproj/project.pbxproj index 02005925..df66660e 100644 --- a/Qonversion.xcodeproj/project.pbxproj +++ b/Qonversion.xcodeproj/project.pbxproj @@ -14,8 +14,10 @@ 6A840E062BD6AE5700E5E8E3 /* RemoteConfigManagerInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A840E052BD6AE5700E5E8E3 /* RemoteConfigManagerInterface.swift */; }; 6A840E092BD6AE7C00E5E8E3 /* EmptyApiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A840E082BD6AE7C00E5E8E3 /* EmptyApiResponse.swift */; }; 6A840E0B2BD6AEE500E5E8E3 /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A840E0A2BD6AEE500E5E8E3 /* ApiError.swift */; }; - 6A840E0E2BD6B2F600E5E8E3 /* RemoteConfigService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A840E0D2BD6B2F600E5E8E3 /* RemoteConfigService.swift */; }; - 6A840E102BD6B30B00E5E8E3 /* RemoteConfigServiceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A840E0F2BD6B30B00E5E8E3 /* RemoteConfigServiceInterface.swift */; }; + 70B4E96E2BD92B9C00EE808C /* StoreKitFacadeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B4E96D2BD92B9C00EE808C /* StoreKitFacadeDelegate.swift */; }; + 70B4E9702BD940F700EE808C /* StoreProductWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B4E96F2BD940F700EE808C /* StoreProductWrapper.swift */; }; + 70B4E9762BDAA7C300EE808C /* RemoteConfigService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B4E9732BDAA7C300EE808C /* RemoteConfigService.swift */; }; + 70B4E9772BDAA7C300EE808C /* RemoteConfigServiceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B4E9742BDAA7C300EE808C /* RemoteConfigServiceInterface.swift */; }; 70CD92F62BC6E22B0039D65C /* MiscAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CD929B2BC6E22B0039D65C /* MiscAssembly.swift */; }; 70CD92F72BC6E22B0039D65C /* QonversionAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CD929C2BC6E22B0039D65C /* QonversionAssembly.swift */; }; 70CD92F82BC6E22B0039D65C /* ServicesAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CD929D2BC6E22B0039D65C /* ServicesAssembly.swift */; }; @@ -94,6 +96,11 @@ 70D877242B8F5B370059AA2B /* Qonversion.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 70F163A22B6D0D3D00033BEF /* Qonversion.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 70EA734C2BD025F500B0DFDA /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA734B2BD025F500B0DFDA /* Currency.swift */; }; 70EA734E2BD0261300B0DFDA /* Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA734D2BD0261300B0DFDA /* Storefront.swift */; }; + 70EA73512BD12C7B00B0DFDA /* ProductsServiceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA73502BD12C7B00B0DFDA /* ProductsServiceInterface.swift */; }; + 70EA73532BD12C8000B0DFDA /* ProductsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA73522BD12C8000B0DFDA /* ProductsService.swift */; }; + 70EA73552BD12CA500B0DFDA /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA73542BD12CA500B0DFDA /* Product.swift */; }; + 70EA735D2BD6B66300B0DFDA /* ProductsManagerInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA735C2BD6B66300B0DFDA /* ProductsManagerInterface.swift */; }; + 70EA735F2BD6B68000B0DFDA /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70EA735E2BD6B68000B0DFDA /* ProductsManager.swift */; }; 7169ED74E32501B128C51E9D /* Pods_Qonversion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3058D2FCF5492D5398F1D64F /* Pods_Qonversion.framework */; }; D265F4B90C3180117593732E /* Pods_Sample.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A982E3A335EC2527556D692D /* Pods_Sample.framework */; }; /* End PBXBuildFile section */ @@ -134,12 +141,14 @@ 6A840E052BD6AE5700E5E8E3 /* RemoteConfigManagerInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigManagerInterface.swift; sourceTree = ""; }; 6A840E082BD6AE7C00E5E8E3 /* EmptyApiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyApiResponse.swift; sourceTree = ""; }; 6A840E0A2BD6AEE500E5E8E3 /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = ""; }; - 6A840E0D2BD6B2F600E5E8E3 /* RemoteConfigService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigService.swift; sourceTree = ""; }; - 6A840E0F2BD6B30B00E5E8E3 /* RemoteConfigServiceInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigServiceInterface.swift; sourceTree = ""; }; 6ABCBDF42B8CE268003DB107 /* PropertiesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesStorage.swift; sourceTree = ""; }; 6ABCBDF62B8CE298003DB107 /* UserPropertiesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesStorage.swift; sourceTree = ""; }; 6ABCBDF82B8DD7A2003DB107 /* SendUserPropertiesResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendUserPropertiesResult.swift; sourceTree = ""; }; 701A06AF0F488249669E14C2 /* Pods-Qonversion.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Qonversion.release.xcconfig"; path = "Target Support Files/Pods-Qonversion/Pods-Qonversion.release.xcconfig"; sourceTree = ""; }; + 70B4E96D2BD92B9C00EE808C /* StoreKitFacadeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitFacadeDelegate.swift; sourceTree = ""; }; + 70B4E96F2BD940F700EE808C /* StoreProductWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductWrapper.swift; sourceTree = ""; }; + 70B4E9732BDAA7C300EE808C /* RemoteConfigService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteConfigService.swift; sourceTree = ""; }; + 70B4E9742BDAA7C300EE808C /* RemoteConfigServiceInterface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteConfigServiceInterface.swift; sourceTree = ""; }; 70CD929B2BC6E22B0039D65C /* MiscAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MiscAssembly.swift; sourceTree = ""; }; 70CD929C2BC6E22B0039D65C /* QonversionAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QonversionAssembly.swift; sourceTree = ""; }; 70CD929D2BC6E22B0039D65C /* ServicesAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServicesAssembly.swift; sourceTree = ""; }; @@ -228,6 +237,11 @@ 70D877892BA19B100059AA2B /* ServicesAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesAssembly.swift; sourceTree = ""; }; 70EA734B2BD025F500B0DFDA /* Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Currency.swift; sourceTree = ""; }; 70EA734D2BD0261300B0DFDA /* Storefront.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront.swift; sourceTree = ""; }; + 70EA73502BD12C7B00B0DFDA /* ProductsServiceInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsServiceInterface.swift; sourceTree = ""; }; + 70EA73522BD12C8000B0DFDA /* ProductsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsService.swift; sourceTree = ""; }; + 70EA73542BD12CA500B0DFDA /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; + 70EA735C2BD6B66300B0DFDA /* ProductsManagerInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManagerInterface.swift; sourceTree = ""; }; + 70EA735E2BD6B68000B0DFDA /* ProductsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = ""; }; 70F163A22B6D0D3D00033BEF /* Qonversion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Qonversion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A982E3A335EC2527556D692D /* Pods_Sample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -272,15 +286,6 @@ path = Entities; sourceTree = ""; }; - 6A840E0C2BD6B2ED00E5E8E3 /* RemoteConfig */ = { - isa = PBXGroup; - children = ( - 6A840E0D2BD6B2F600E5E8E3 /* RemoteConfigService.swift */, - 6A840E0F2BD6B30B00E5E8E3 /* RemoteConfigServiceInterface.swift */, - ); - path = RemoteConfig; - sourceTree = ""; - }; 6ABCBE0F2B91C86D003DB107 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -298,6 +303,15 @@ name = "Recovered References"; sourceTree = ""; }; + 70B4E9752BDAA7C300EE808C /* RemoteConfigService */ = { + isa = PBXGroup; + children = ( + 70B4E9732BDAA7C300EE808C /* RemoteConfigService.swift */, + 70B4E9742BDAA7C300EE808C /* RemoteConfigServiceInterface.swift */, + ); + path = RemoteConfigService; + sourceTree = ""; + }; 70CD929E2BC6E22B0039D65C /* Assemblies */ = { isa = PBXGroup; children = ( @@ -319,6 +333,8 @@ 70CD92A22BC6E22B0039D65C /* UserProperties.swift */, 70CD92A32BC6E22B0039D65C /* UserProperty.swift */, 70CD92A42BC6E22B0039D65C /* UserPropertyKey.swift */, + 70EA73542BD12CA500B0DFDA /* Product.swift */, + 70B4E96F2BD940F700EE808C /* StoreProductWrapper.swift */, 6A840DF82BD6ADAD00E5E8E3 /* Experiment.swift */, 6A840DFC2BD6ADD700E5E8E3 /* RemoteConfig.swift */, 6A840DFE2BD6ADEA00E5E8E3 /* RemoteConfigList.swift */, @@ -348,6 +364,7 @@ 70CD92AD2BC6E22B0039D65C /* Managers */ = { isa = PBXGroup; children = ( + 70EA735B2BD6B63300B0DFDA /* ProductsManager */, 6A840E022BD6AE2E00E5E8E3 /* RemoteConfig */, 70CD92A82BC6E22B0039D65C /* Device */, 70CD92AC2BC6E22B0039D65C /* UserProperties */, @@ -505,7 +522,8 @@ 70CD92DB2BC6E22B0039D65C /* Services */ = { isa = PBXGroup; children = ( - 6A840E0C2BD6B2ED00E5E8E3 /* RemoteConfig */, + 70B4E9752BDAA7C300EE808C /* RemoteConfigService */, + 70EA734F2BD12C7300B0DFDA /* ProductsService */, 70CD92D72BC6E22B0039D65C /* Device */, 70CD92DA2BC6E22B0039D65C /* UserService */, ); @@ -517,6 +535,7 @@ children = ( 70CD92DC2BC6E22B0039D65C /* StoreKitFacade.swift */, 70CD92DD2BC6E22B0039D65C /* StoreKitFacadeInterface.swift */, + 70B4E96D2BD92B9C00EE808C /* StoreKitFacadeDelegate.swift */, 70CD92DE2BC6E22B0039D65C /* StoreKitMapper.swift */, 70CD92DF2BC6E22B0039D65C /* StoreKitMapperInterface.swift */, 70CD92E02BC6E22B0039D65C /* StoreKitOldWrapper.swift */, @@ -588,6 +607,24 @@ path = Sample; sourceTree = ""; }; + 70EA734F2BD12C7300B0DFDA /* ProductsService */ = { + isa = PBXGroup; + children = ( + 70EA73502BD12C7B00B0DFDA /* ProductsServiceInterface.swift */, + 70EA73522BD12C8000B0DFDA /* ProductsService.swift */, + ); + path = ProductsService; + sourceTree = ""; + }; + 70EA735B2BD6B63300B0DFDA /* ProductsManager */ = { + isa = PBXGroup; + children = ( + 70EA735C2BD6B66300B0DFDA /* ProductsManagerInterface.swift */, + 70EA735E2BD6B68000B0DFDA /* ProductsManager.swift */, + ); + path = ProductsManager; + sourceTree = ""; + }; 70F163982B6D0D3D00033BEF = { isa = PBXGroup; children = ( @@ -801,9 +838,10 @@ 70CD93082BC6E22B0039D65C /* HeadersBuilder.swift in Sources */, 6A840E062BD6AE5700E5E8E3 /* RemoteConfigManagerInterface.swift in Sources */, 70CD93372BC6E22B0039D65C /* StorageConstants.swift in Sources */, - 6A840E102BD6B30B00E5E8E3 /* RemoteConfigServiceInterface.swift in Sources */, 70CD931E2BC6E22B0039D65C /* DeviceService.swift in Sources */, + 70EA73532BD12C8000B0DFDA /* ProductsService.swift in Sources */, 70CD932C2BC6E22B0039D65C /* ConcurrencyExtensions.swift in Sources */, + 70EA735F2BD6B68000B0DFDA /* ProductsManager.swift in Sources */, 70EA734C2BD025F500B0DFDA /* Currency.swift in Sources */, 70CD92FD2BC6E22B0039D65C /* UserProperty.swift in Sources */, 70EA734E2BD0261300B0DFDA /* Storefront.swift in Sources */, @@ -820,17 +858,22 @@ 70CD92F62BC6E22B0039D65C /* MiscAssembly.swift in Sources */, 70CD93022BC6E22B0039D65C /* UserPropertiesManager.swift in Sources */, 70CD93012BC6E22B0039D65C /* SendUserPropertiesResult.swift in Sources */, + 70B4E9762BDAA7C300EE808C /* RemoteConfigService.swift in Sources */, + 70B4E96E2BD92B9C00EE808C /* StoreKitFacadeDelegate.swift in Sources */, 70CD930A2BC6E22B0039D65C /* NetworkErrorHandler.swift in Sources */, 70CD92F82BC6E22B0039D65C /* ServicesAssembly.swift in Sources */, 70CD93032BC6E22B0039D65C /* UserPropertiesManagerInterface.swift in Sources */, 70CD93252BC6E22B0039D65C /* StoreKitMapperInterface.swift in Sources */, 6A840E042BD6AE4300E5E8E3 /* RemoteConfigManager.swift in Sources */, 70CD93202BC6E22B0039D65C /* UserService.swift in Sources */, + 70EA73552BD12CA500B0DFDA /* Product.swift in Sources */, 70CD93232BC6E22B0039D65C /* StoreKitFacadeInterface.swift in Sources */, 70CD93192BC6E22B0039D65C /* LocalStorageInterface.swift in Sources */, + 70EA735D2BD6B66300B0DFDA /* ProductsManagerInterface.swift in Sources */, 70CD932A2BC6E22B0039D65C /* StoreKitWrapperDelegate.swift in Sources */, 70CD93072BC6E22B0039D65C /* Header.swift in Sources */, 70CD93122BC6E22B0039D65C /* RequestProcessor.swift in Sources */, + 70EA73512BD12C7B00B0DFDA /* ProductsServiceInterface.swift in Sources */, 70CD93062BC6E22B0039D65C /* QonversionErrorType.swift in Sources */, 70CD932D2BC6E22B0039D65C /* IncrementalDelayCalculator.swift in Sources */, 70CD93102BC6E22B0039D65C /* Request.swift in Sources */, @@ -841,6 +884,7 @@ 70CD930F2BC6E22B0039D65C /* RateLimiterInterface.swift in Sources */, 70CD931F2BC6E22B0039D65C /* DeviceServiceInterface.swift in Sources */, 70CD93362BC6E22B0039D65C /* Qonversion.swift in Sources */, + 70B4E9702BD940F700EE808C /* StoreProductWrapper.swift in Sources */, 6A840DFD2BD6ADD700E5E8E3 /* RemoteConfig.swift in Sources */, 70CD93342BC6E22B0039D65C /* Qonversion.docc in Sources */, 70CD92FB2BC6E22B0039D65C /* User.swift in Sources */, @@ -857,8 +901,8 @@ 70CD932B2BC6E22B0039D65C /* StoreKitWrapperInterface.swift in Sources */, 70CD93162BC6E22B0039D65C /* ResponseDecoder.swift in Sources */, 70CD93262BC6E22B0039D65C /* StoreKitOldWrapper.swift in Sources */, - 6A840E0E2BD6B2F600E5E8E3 /* RemoteConfigService.swift in Sources */, 6A840E092BD6AE7C00E5E8E3 /* EmptyApiResponse.swift in Sources */, + 70B4E9772BDAA7C300EE808C /* RemoteConfigServiceInterface.swift in Sources */, 70CD93242BC6E22B0039D65C /* StoreKitMapper.swift in Sources */, 70CD931C2BC6E22B0039D65C /* DeviceInfoCollector.swift in Sources */, 70CD93112BC6E22B0039D65C /* RequestType.swift in Sources */, diff --git a/Sources/Assemblies/MiscAssembly.swift b/Sources/Assemblies/MiscAssembly.swift index 7772f94d..9441a867 100644 --- a/Sources/Assemblies/MiscAssembly.swift +++ b/Sources/Assemblies/MiscAssembly.swift @@ -7,6 +7,7 @@ import Foundation import OSLog +import StoreKit fileprivate enum SDKLevelConstants: String { case version = "1.0" @@ -111,4 +112,8 @@ final class MiscAssembly { return headersBuilder } + + func paymentQueue() -> SKPaymentQueue { + return SKPaymentQueue.default() + } } diff --git a/Sources/Assemblies/QonversionAssembly.swift b/Sources/Assemblies/QonversionAssembly.swift index 87c53326..7c1b026f 100644 --- a/Sources/Assemblies/QonversionAssembly.swift +++ b/Sources/Assemblies/QonversionAssembly.swift @@ -38,6 +38,18 @@ final class QonversionAssembly { return deviceManager } + func productsManager() -> ProductsManagerInterface { + let productsService: ProductsServiceInterface = servicesAssembly.productsService() + let storeKitFacade: StoreKitFacade = servicesAssembly.storeKitFacade() + let localStorage: LocalStorageInterface = miscAssembly.localStorage() + let logger: LoggerWrapper = miscAssembly.loggerWrapper() + let productsManager = ProductsManager(productsService: productsService, storeKitFacade: storeKitFacade, localStorage: localStorage, logger: logger) + + storeKitFacade.delegate = productsManager + + return productsManager + } + func remoteConfigManager() -> RemoteConfigManagerInterface { let remoteConfigService = servicesAssembly.remoteConfigService() let logger: LoggerWrapper = miscAssembly.loggerWrapper() diff --git a/Sources/Assemblies/ServicesAssembly.swift b/Sources/Assemblies/ServicesAssembly.swift index 35cbc892..0f244dfc 100644 --- a/Sources/Assemblies/ServicesAssembly.swift +++ b/Sources/Assemblies/ServicesAssembly.swift @@ -31,6 +31,53 @@ final class ServicesAssembly { return userService } + func productsService() -> ProductsServiceInterface { + let requestProcessor = requestProcessor() + let productsService = ProductsService(requestProcessor: requestProcessor, internalConfig: miscAssembly.internalConfig) + + return productsService + } + + func storeKitMapper() -> StoreKitMapperInterface { + let mapper = StoreKitMapper() + + return mapper + } + + func storeKitFacade() -> StoreKitFacade { + let mapper: StoreKitMapperInterface = storeKitMapper() + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + let wrapper: StoreKitWrapper = storeKitWrapper() + + let storeKitFacade = StoreKitFacade(storeKitWrapper: wrapper, storeKitMapper: mapper) + + wrapper.delegate = storeKitFacade + + return storeKitFacade + } else { + let wrapper: StoreKitOldWrapper = storeKitOldWrapper() + + let storeKitFacade = StoreKitFacade(storeKitOldWrapper: wrapper, storeKitMapper: mapper) + wrapper.delegate = storeKitFacade + + return storeKitFacade + } + + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func storeKitWrapper() -> StoreKitWrapper { + let storeKitWrapper = StoreKitWrapper() + + return storeKitWrapper + } + + func storeKitOldWrapper() -> StoreKitOldWrapper { + let storeKitOldWrapper = StoreKitOldWrapper(paymentQueue: miscAssembly.paymentQueue()) + + return storeKitOldWrapper + } + func deviceService() -> DeviceServiceInterface { let requestProcessor: RequestProcessorInterface = requestProcessor() let localStorage: LocalStorageInterface = miscAssembly.localStorage() diff --git a/Sources/Entities/Product.swift b/Sources/Entities/Product.swift new file mode 100644 index 00000000..cdee167f --- /dev/null +++ b/Sources/Entities/Product.swift @@ -0,0 +1,478 @@ +// +// Product.swift +// Qonversion +// +// Created by Suren Sarkisyan on 18.04.2024. +// + +import Foundation +import StoreKit + +extension Qonversion { + + public struct Product: Decodable { + + /// The unique Qonversion product identifier. + public let qonversionId: String + + /// The AppStore product identifier. + public let storeId: String + + /// The Qonversion offering identifier if product linked to an offering or nil. + public let offeringId: String? + + /// The localized display name of the product, if it exists. + public var displayName: String? { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let storeProduct { + return storeProduct.displayName + } else if let skProduct { + return skProduct.localizedTitle + } + + return nil + } + + /// The localized description of the product. + public var description: String? { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let storeProduct { + return storeProduct.description + } else if let skProduct { + return skProduct.localizedDescription + } + + return nil + } + + /// The localized string representation of the product price, suitable for display. + public var displayPrice: String? { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let storeProduct { + return storeProduct.displayPrice + } else if let skProduct { + return skProduct.displayPrice() + } + + return nil + } + + /// The decimal representation of the cost of the product, in local currency. + public var price: Decimal? { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let storeProduct { + return storeProduct.price + } else if let skProduct { + return skProduct.price as Decimal + } + + return nil + } + + /// The raw JSON representation of the product information. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var jsonRepresentation: Data? { storeProduct?.jsonRepresentation } + + /// Whether the product is available for family sharing. + /// From iOS 14.0, macOS 11.0, watchOS 7.0, visionOS 1.0 available for the old StoreKit 1 products + /// From iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0 available for the new StoreKit 2 products + @available(iOS 14.0, macOS 11.0, watchOS 7.0, visionOS 1.0, *) + public var isFamilyShareable: Bool? { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let product = storeProduct { + return product.isFamilyShareable + } else if let product = skProduct { + return product.isFamilyShareable + } + + return nil + } + + /// The format style to use when formatting numbers derived from the price for the product. + /// + /// Use `displayPrice` when possible. Use `priceFormatStyle` only for localizing numbers + /// derived from the `price` property, such as "2 products for $(`price * 2`)". + /// - Important: When using `priceFormatStyle` on systems earlier than iOS 16.0, + /// macOS 13.0, tvOS 16.0 or watchOS 9.0, the property may return a format style + /// with a sentinel locale with identifier "xx\_XX" in some uncommon cases: + /// (1) StoreKit Testing in Xcode (workaround: test your app on a device running a + /// more recent OS) or (2) a critical server error. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + @backDeployed(before: iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, macCatalyst 16.0) + public var priceFormatStyle: Decimal.FormatStyle.Currency? { storeProduct?.priceFormatStyle } + + /// The original StoreKit 1 product. + /// + /// For StoreKit 2 product use ``Qonversion/Qonversion/Product/storeProduct`` . + public var skProduct: SKProduct? + + /// Whether the store product is loaded and linked or not. + public var isStoreProductLinked: Bool { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + return storeProduct != nil || skProduct != nil + } else { + return skProduct != nil + } + } + + /// The type of the product. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var type: Qonversion.Product.ProductType? { Qonversion.Product.ProductType.from(type: storeProduct?.type) } + + // The original StoreKit 2 product. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var storeProduct: StoreKit.Product? { _storeProduct as? StoreKit.Product } + + /// The format style to use when formatting subscription periods for the subscription. + /// + /// Use the `formatted(_:referenceDate:)` method on `Product.SubscriptionPeriod` + /// with this style to format the subscription period for the App Store locale for the subscription. + /// - Important: When using `subscriptionPeriodFormatStyle` on systems earlier than + /// iOS 16.0, macOS 13.0, tvOS 16.0 or watchOS 9.0, the property may return a + /// format style with a sentinel locale with identifier "xx\_XX" in some uncommon cases: + /// (1) StoreKit Testing in Xcode (workaround: test your app on a device running a + /// more recent OS) or (2) a critical server error. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + @backDeployed(before: iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, macCatalyst 16.0) + public var subscriptionPeriodFormatStyle: Date.ComponentsFormatStyle? { storeProduct?.subscriptionPeriodFormatStyle } + + /// The format style to use when formatting subscription period units for the subscription. + /// + /// Use the `formatted(_:)` method on `Product.SubscriptionPeriod.Unit` with this + /// style to format the subscription period for the App Store locale for the subscription. + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) + public var subscriptionPeriodUnitFormatStyle: StoreKit.Product.SubscriptionPeriod.Unit.FormatStyle? { storeProduct?.subscriptionPeriodUnitFormatStyle } + + /// Properties and functionality specific to auto-renewable subscriptions. + /// + /// This is never `nil` if `type` is `.autoRenewable`, and always `nil` for all other product + /// types. + public var subscription: Qonversion.Product.SubscriptionInfo? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + qonversionId = try container.decode(String.self, forKey: .qonversionId) + storeId = try container.decode(String.self, forKey: .storeId) + offeringId = try container.decode(String.self, forKey: .offeringId) + skProduct = nil + } + + init(qonversionId: String, storeId: String, offeringId: String?) { + self.qonversionId = qonversionId + self.storeId = storeId + self.offeringId = offeringId + } + + // MARK: - Nested structures and enums + + /// Subscription period details.. + public struct SubscriptionPeriod { + + /// The unit of time that this period represents. + public let unit: Qonversion.Product.SubscriptionPeriod.Unit + + /// The number of units that the period represents. + public let value: Int + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(originalPeriod: StoreKit.Product.SubscriptionPeriod) { + value = originalPeriod.value + unit = Qonversion.Product.SubscriptionPeriod.Unit.from(unit: originalPeriod.unit) + } + + init(subscriptionPeriod: SKProductSubscriptionPeriod) { + value = subscriptionPeriod.numberOfUnits + unit = Qonversion.Product.SubscriptionPeriod.Unit.from(unit: subscriptionPeriod.unit) + } + + // MARK: Nested structs & enums + + /// Unit type of a subscription period. + public enum Unit { + + /// For rare cases when the subscription period unit can't be determined. + case unknown + + /// A subscription period unit of a day. + case day + + /// A subscription period unit of a week. + case week + + /// A subscription period unit of a month. + case month + + /// A subscription period unit of a year. + case year + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + static func from(unit: StoreKit.Product.SubscriptionPeriod.Unit?) -> Qonversion.Product.SubscriptionPeriod.Unit { + guard let unit: StoreKit.Product.SubscriptionPeriod.Unit = unit else { return .unknown } + + switch unit { + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + default: + return .unknown + } + } + + static func from(unit: SKProduct.PeriodUnit) -> Qonversion.Product.SubscriptionPeriod.Unit { + switch unit { + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + default: + return .unknown + } + } + + } + } + + /// Information about a subscription offer configured in App Store Connect. + public struct SubscriptionOffer { + + /// The promotional offer identifier. + /// + /// This is always `nil` for introductory offers and never `nil` for promotional offers. + public let id: String? + + /// The type of the offer. + public let type: Qonversion.Product.SubscriptionOffer.OfferType + + /// The discounted price that the offer provides in local currency. + /// + /// This is the price per period in the case of `.payAsYouGo` + public let price: Decimal + + /// A localized string representation of `price`. + public let displayPrice: String + + /// The duration that this offer lasts before auto-renewing or changing to standard subscription + /// renewals. + public let period: Qonversion.Product.SubscriptionPeriod + + /// The number of periods this offer will renew for. + /// + /// Always 1 except for `.payAsYouGo`. + public let periodCount: Int + + /// How the user is charged for this offer. + public let paymentMode: Qonversion.Product.SubscriptionOffer.PaymentMode + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init?(originalOffer: StoreKit.Product.SubscriptionOffer?) { + guard let originalOffer else { return nil } + id = originalOffer.id + type = Qonversion.Product.SubscriptionOffer.OfferType.from(offerType: originalOffer.type) + price = originalOffer.price + displayPrice = originalOffer.displayPrice + period = Qonversion.Product.SubscriptionPeriod(originalPeriod: originalOffer.period) + periodCount = originalOffer.periodCount + paymentMode = Qonversion.Product.SubscriptionOffer.PaymentMode.from(paymentMode: originalOffer.paymentMode) + } + + init?(introductoryPrice: SKProductDiscount?) { + guard let introductoryPrice else { return nil } + + id = introductoryPrice.identifier + type = Qonversion.Product.SubscriptionOffer.OfferType.from(introductoryPrice: introductoryPrice) + price = introductoryPrice.price as Decimal + displayPrice = introductoryPrice.displayPrice() ?? "" + period = Qonversion.Product.SubscriptionPeriod(subscriptionPeriod: introductoryPrice.subscriptionPeriod) + periodCount = introductoryPrice.subscriptionPeriod.numberOfUnits + paymentMode = Qonversion.Product.SubscriptionOffer.PaymentMode.from(oldPaymentMode: introductoryPrice.paymentMode) + } + + // MARK: Nested structs & enums + + /// The type of the subscription offer. + public enum OfferType { + + /// In case the offer type can't be determined. + case unknown + + /// An introductory offer for a subscription. + case introductory + + /// A promotional offer. + case promotional + + /// Available only for StoreKit 1 SKProductDiscount + case subscription + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + static func from(offerType: StoreKit.Product.SubscriptionOffer.OfferType?) -> Qonversion.Product.SubscriptionOffer.OfferType { + guard let offerType else { return .unknown } + switch offerType { + case .introductory: + return .introductory + case .promotional: + return .promotional + default: + return .unknown + } + } + + static func from(introductoryPrice: SKProductDiscount) -> Qonversion.Product.SubscriptionOffer.OfferType { + switch introductoryPrice.type { + case .introductory: + return .introductory + case .subscription: + return .subscription + default: + return .unknown + } + } + } + + /// Payment mode for a product + public enum PaymentMode { + + /// For rare cases when the payment mode can't be determined. + case unknown + + /// A payment mode of a product discount that indicates the discount applies over a single billing period or multiple billing periods. + case payAsYouGo + + /// A payment mode of a product discount that indicates the system applies the discount up front. + case payUpFront + + /// A payment mode of a product discount that indicates a free trial offer. + case freeTrial + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + static func from(paymentMode: StoreKit.Product.SubscriptionOffer.PaymentMode?) -> Qonversion.Product.SubscriptionOffer.PaymentMode { + guard let mode: StoreKit.Product.SubscriptionOffer.PaymentMode = paymentMode else { return .unknown } + + switch mode { + case .payUpFront: + return .payUpFront + case .payAsYouGo: + return .payAsYouGo + case .freeTrial: + return .freeTrial + default: + return .unknown + } + } + + static func from(oldPaymentMode: SKProductDiscount.PaymentMode) -> Qonversion.Product.SubscriptionOffer.PaymentMode { + switch oldPaymentMode { + case .payUpFront: + return .payUpFront + case .payAsYouGo: + return .payAsYouGo + case .freeTrial: + return .freeTrial + default: + return .unknown + } + } + + } + } + + /// Information about an auto-renewable subscription, such as its status, period, subscription group, and subscription offer details. + public struct SubscriptionInfo { + + /// An optional introductory offer that will automatically be applied if the user is eligible. + public let introductoryOffer: Qonversion.Product.SubscriptionOffer? + + /// An array of all the promotional offers configured for this subscription. + public let promotionalOffers: [Qonversion.Product.SubscriptionOffer] + + /// The group identifier for this subscription. + public let subscriptionGroupId: String + + /// The duration that this subscription lasts before auto-renewing. + public let subscriptionPeriod: Qonversion.Product.SubscriptionPeriod + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init?(originalSubscription: StoreKit.Product.SubscriptionInfo?) { + guard let originalSubscription else { return nil } + + introductoryOffer = Qonversion.Product.SubscriptionOffer(originalOffer: originalSubscription.introductoryOffer) + promotionalOffers = originalSubscription.promotionalOffers.compactMap { + Qonversion.Product.SubscriptionOffer(originalOffer: $0) + } + subscriptionGroupID = originalSubscription.subscriptionGroupID + subscriptionPeriod = Qonversion.Product.SubscriptionPeriod(originalPeriod: originalSubscription.subscriptionPeriod) + } + + init?(oldProduct: SKProduct?) { + guard let oldProduct, let subscriptionGroupId = oldProduct.subscriptionGroupIdentifier, let skSubscriptionPeriod = oldProduct.subscriptionPeriod else { return nil } + + subscriptionGroupID = subscriptionGroupId + introductoryOffer = Qonversion.Product.SubscriptionOffer(introductoryPrice: oldProduct.introductoryPrice) + subscriptionPeriod = Qonversion.Product.SubscriptionPeriod(subscriptionPeriod: skSubscriptionPeriod) + promotionalOffers = oldProduct.discounts.compactMap { + Qonversion.Product.SubscriptionOffer(introductoryPrice: $0) + } + } + } + + /// The types of in-app purchases. + public enum ProductType { + + /// A consumable in-app purchase. + case consumable + + /// A non-consumable in-app purchase. + case nonConsumable + + /// A non-renewing subscription. + case nonRenewable + + /// An auto-renewable subscription. + case autoRenewable + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + static func from(type: StoreKit.Product.ProductType?) -> Qonversion.Product.ProductType? { + guard let type: StoreKit.Product.ProductType = type else { return nil } + + switch type { + case .consumable: + return .consumable + case .nonConsumable: + return .nonConsumable + case .nonRenewable: + return .nonRenewable + case .autoRenewable: + return .autoRenewable + default: + return nil + } + } + } + + // MARK: - Private + + // Internal workaround + var _storeProduct: Any? + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + mutating func enrich(storeProduct: StoreKit.Product) { + self._storeProduct = storeProduct + self.subscription = Qonversion.Product.SubscriptionInfo(originalSubscription: storeProduct.subscription) + } + + mutating func enrich(skProduct: SKProduct) { + self.skProduct = skProduct + self.subscription = Qonversion.Product.SubscriptionInfo(oldProduct: skProduct) + } + + private enum CodingKeys: String, CodingKey { + case qonversionId + case storeId + case offeringId + } + } +} diff --git a/Sources/Entities/StoreProductWrapper.swift b/Sources/Entities/StoreProductWrapper.swift new file mode 100644 index 00000000..76f24f5c --- /dev/null +++ b/Sources/Entities/StoreProductWrapper.swift @@ -0,0 +1,33 @@ +// +// StoreProductWrapper.swift +// Qonversion +// +// Created by Suren Sarkisyan on 24.04.2024. +// + +import Foundation +import StoreKit + +struct StoreProductWrapper { + + var id: String? { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + return product?.id + } else { + return oldProduct?.productIdentifier + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + var product: StoreKit.Product? { _product as? StoreKit.Product } + + let oldProduct: SKProduct? + + private let _product: Any? + + init(_product: Any?, oldProduct: SKProduct?) { + self._product = _product + self.oldProduct = oldProduct + } + +} diff --git a/Sources/Entities/Transaction.swift b/Sources/Entities/Transaction.swift index 36c2ae64..902d24a2 100644 --- a/Sources/Entities/Transaction.swift +++ b/Sources/Entities/Transaction.swift @@ -13,6 +13,116 @@ extension Qonversion { /// StoreKit [Transaction](https://developer.apple.com/documentation/storekit/transaction) wrapper. public struct Transaction { + /// The raw JSON representation of the transaction information. + public var jsonRepresentation: Data? + + /// The unique identifier for the transaction. + public let id: String? + + /// The original transaction identifier of a purchase. + public let originalId: String? + + /// A unique ID that identifies subscription purchase events across devices, including subscription renewals. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var webOrderLineItemId: String? { storeKitTransaction?.webOrderLineItemID } + + /// The product identifier of the in-app purchase. + public let productId: String + + /// The identifier of the subscription group that the subscription belongs to. + public let subscriptionGroupId: String? + + /// The bundle identifier for the app. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var appBundleId: String? { storeKitTransaction?.appBundleID } + + /// The date that the App Store charged the user’s account for a purchased or restored product, or for a subscription purchase or renewal after a lapse. + public let purchaseDate: Date? + + /// The date of purchase for the original transaction. + public let originalPurchaseDate: Date? + + /// The date the subscription expires or renews. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var expirationDate: Date? { storeKitTransaction?.expirationDate } + + /// The number of consumable products purchased. + public let purchasedQuantity: Int + + /// A Boolean that indicates whether the user upgraded to another subscription. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var isUpgraded: Bool? { storeKitTransaction?.isUpgraded } + + /// The subscription offer that applies to the transaction, including its offer type, payment mode, and ID. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var offer: Qonversion.Transaction.Offer? { _offer } + + /// The reason that the App Store refunded the transaction or revoked it from Family Sharing. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var revocationReason: Qonversion.Transaction.RevocationReason? { Qonversion.Transaction.RevocationReason.from(revocataionReason: storeKitTransaction?.revocationReason) } + + /// The date that the App Store refunded the transaction or revoked it from Family Sharing. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var revocationDate: Date? { storeKitTransaction?.revocationDate } + + /// A UUID that associates the transaction with a user on your own service. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var appAccountToken: UUID? { storeKitTransaction?.appAccountToken } + + /// The Apple server environment that generates and signs the transaction. + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) + public var environment: Qonversion.Transaction.Environment? { Qonversion.Transaction.Environment(rawValue: storeKitTransaction?.environment.rawValue ?? "") } + + /// A cause of a purchase transaction, indicating whether it’s a customer’s purchase or an auto-renewable subscription renewal that the system initiates. + @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) + public var reason: Qonversion.Transaction.Reason { + guard let storeKitTransaction = storeKitTransaction, + let reason = Qonversion.Transaction.Reason(rawValue: storeKitTransaction.reason.rawValue) + else { return Qonversion.Transaction.Reason.purchase } + + return reason + } + + /// The decimal representation of the cost of the product, in local currency. + public let price: Decimal? + + /// Transaction currency info. + public let currency: Qonversion.Currency? + + /// Transaction storefront info. + public let storefront: Qonversion.Storefront? + + /// The device verification value to use to verify whether the renewal information belongs to the device. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var deviceVerification: Data? { storeKitTransaction?.deviceVerification } + + /// The UUID to use to compute the device verification value. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var deviceVerificationNonce: UUID? { storeKitTransaction?.deviceVerificationNonce } + + /// The date that the App Store signed the JWS renewal information. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var signedDate: Date? { storeKitTransaction?.signedDate } + + /// A value that indicates whether the transaction was purchased by the user, or is made available to them through Family Sharing. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var ownershipType: Qonversion.Transaction.OwnershipType { + guard let storeKitTransaction = storeKitTransaction, + let ownershipType = Qonversion.Transaction.OwnershipType(rawValue: storeKitTransaction.ownershipType.rawValue) + else { return Qonversion.Transaction.OwnershipType.purchased } + + return ownershipType + } + + /// Original StoreKit 2 Transaction. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public var storeKitTransaction: StoreKit.Transaction? { _storeKitTransaction as? StoreKit.Transaction } + + /// Original old StoreKit Transaction + public let skPaymentTransaction: SKPaymentTransaction? + + // MARK: - Nested structures and enums + /// Enum describes possible reasons of transaction's revocation. /// This enum is a wrapper of StoreKit Transaction's [RevocationReason](https://developer.apple.com/documentation/storekit/transaction/revocationreason) public enum RevocationReason { @@ -67,6 +177,43 @@ extension Qonversion { /// The subscription offers that apply to a transaction. public struct Offer { + /// A string that identifies the subscription offer that applies to the transaction. + public let id: String? + + /// The type of subscription offer that applies to the transaction. + public let type: Transaction.Offer.OfferType? + + /// The payment modes for subscription offers that apply to a transaction. + @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) + public var paymentMode: Qonversion.Transaction.Offer.PaymentMode? { + guard let offer = _offer as? StoreKit.Transaction.Offer else { return nil } + return Qonversion.Transaction.Offer.PaymentMode.from(paymentMode: offer.paymentMode) + } + + /// Original object of StoreKit Transaction [Offer](https://developer.apple.com/documentation/storekit/transaction/offer) + @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) + public var originalOffer: StoreKit.Transaction.Offer? { _offer as? StoreKit.Transaction.Offer } + + // Workaround to make originalOffer variable available for specific OS versions + let _offer: Any? + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init?(with transaction: StoreKit.Transaction) { + self.type = Qonversion.Transaction.Offer.OfferType.from(transaction: transaction) + + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { + guard let offer = transaction.offer else { return nil } + + self._offer = offer + self.id = offer.id + } else { + self.id = transaction.offerID + self._offer = nil + } + } + + // MARK: Nested stucts & enums + /// The types of offers for auto-renewable subscriptions. public enum OfferType: String { @@ -132,42 +279,6 @@ extension Qonversion { } } } - - /// A string that identifies the subscription offer that applies to the transaction. - public let id: String? - - /// The type of subscription offer that applies to the transaction. - public let type: Transaction.Offer.OfferType? - - /// The payment modes for subscription offers that apply to a transaction. - @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) - public var paymentMode: Qonversion.Transaction.Offer.PaymentMode? { - guard let offer = _offer as? StoreKit.Transaction.Offer else { return nil } - return Qonversion.Transaction.Offer.PaymentMode.from(paymentMode: offer.paymentMode) - } - - /// Original object of StoreKit Transaction [Offer](https://developer.apple.com/documentation/storekit/transaction/offer) - @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) - public var originalOffer: StoreKit.Transaction.Offer? { _offer as? StoreKit.Transaction.Offer } - - // Workaround to make originalOffer variable available for specific OS versions - let _offer: Any? - - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - init?(with transaction: StoreKit.Transaction) { - self.type = Qonversion.Transaction.Offer.OfferType.from(transaction: transaction) - - if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { - guard let offer = transaction.offer else { return nil } - - self._offer = offer - self.id = offer.id - } else { - self.id = transaction.offerID - self._offer = nil - } - } - } /// A cause of a purchase transaction, indicating whether it’s a customer’s purchase or an auto-renewable subscription renewal that the system initiates. @@ -181,114 +292,6 @@ extension Qonversion { } - /// The raw JSON representation of the transaction information. - public var jsonRepresentation: Data? - - /// The unique identifier for the transaction. - public let id: String? - - /// The original transaction identifier of a purchase. - public let originalId: String? - - /// A unique ID that identifies subscription purchase events across devices, including subscription renewals. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var webOrderLineItemId: String? { storeKitTransaction?.webOrderLineItemID } - - /// The product identifier of the in-app purchase. - public let productId: String - - /// The identifier of the subscription group that the subscription belongs to. - public let subscriptionGroupId: String? - - /// The bundle identifier for the app. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var appBundleId: String? { storeKitTransaction?.appBundleID } - - /// The date that the App Store charged the user’s account for a purchased or restored product, or for a subscription purchase or renewal after a lapse. - public let purchaseDate: Date? - - /// The date of purchase for the original transaction. - public let originalPurchaseDate: Date? - - /// The date the subscription expires or renews. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var expirationDate: Date? { storeKitTransaction?.expirationDate } - - /// The number of consumable products purchased. - public let purchasedQuantity: Int - - /// A Boolean that indicates whether the user upgraded to another subscription. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var isUpgraded: Bool? { storeKitTransaction?.isUpgraded } - - /// The subscription offer that applies to the transaction, including its offer type, payment mode, and ID. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var offer: Qonversion.Transaction.Offer? { _offer } - - /// The reason that the App Store refunded the transaction or revoked it from Family Sharing. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var revocationReason: Qonversion.Transaction.RevocationReason? { Qonversion.Transaction.RevocationReason.from(revocataionReason: storeKitTransaction?.revocationReason) } - - /// The date that the App Store refunded the transaction or revoked it from Family Sharing. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var revocationDate: Date? { storeKitTransaction?.revocationDate } - - /// A UUID that associates the transaction with a user on your own service. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var appAccountToken: UUID? { storeKitTransaction?.appAccountToken } - - /// The Apple server environment that generates and signs the transaction. - @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) - public var environment: Qonversion.Transaction.Environment? { Qonversion.Transaction.Environment(rawValue: storeKitTransaction?.environment.rawValue ?? "") } - - /// A cause of a purchase transaction, indicating whether it’s a customer’s purchase or an auto-renewable subscription renewal that the system initiates. - @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) - public var reason: Qonversion.Transaction.Reason { - guard let storeKitTransaction = storeKitTransaction, - let reason = Qonversion.Transaction.Reason(rawValue: storeKitTransaction.reason.rawValue) - else { return Qonversion.Transaction.Reason.purchase } - - return reason - } - - /// The decimal representation of the cost of the product, in local currency. - public let price: Decimal? - - /// Transaction currency info. - public let currency: Qonversion.Currency? - - /// Transaction storefront info. - public let storefront: Qonversion.Storefront? - - /// The device verification value to use to verify whether the renewal information belongs to the device. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var deviceVerification: Data? { storeKitTransaction?.deviceVerification } - - /// The UUID to use to compute the device verification value. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var deviceVerificationNonce: UUID? { storeKitTransaction?.deviceVerificationNonce } - - /// The date that the App Store signed the JWS renewal information. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var signedDate: Date? { storeKitTransaction?.signedDate } - - /// A value that indicates whether the transaction was purchased by the user, or is made available to them through Family Sharing. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var ownershipType: Qonversion.Transaction.OwnershipType { - guard let storeKitTransaction = storeKitTransaction, - let ownershipType = Qonversion.Transaction.OwnershipType(rawValue: storeKitTransaction.ownershipType.rawValue) - else { return Qonversion.Transaction.OwnershipType.purchased } - - return ownershipType - } - - /// Original StoreKit 2 Transaction. - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - public var storeKitTransaction: StoreKit.Transaction? { _storeKitTransaction as? StoreKit.Transaction } - - /// Original old StoreKit Transaction - public let skPaymentTransaction: SKPaymentTransaction? - // MARK: - Private private let _storeKitTransaction: Any? diff --git a/Sources/Managers/ProductsManager/ProductsManager.swift b/Sources/Managers/ProductsManager/ProductsManager.swift new file mode 100644 index 00000000..2b9ac671 --- /dev/null +++ b/Sources/Managers/ProductsManager/ProductsManager.swift @@ -0,0 +1,76 @@ +// +// ProductsManager.swift +// Qonversion +// +// Created by Suren Sarkisyan on 22.04.2024. +// + +import Foundation +import StoreKit + +final class ProductsManager: ProductsManagerInterface { + + let productsService: ProductsServiceInterface + let storeKitFacade: StoreKitFacadeInterface + let localStorage: LocalStorageInterface + private let logger: LoggerWrapper + + var loadedProducts: [Qonversion.Product] = [] + + init(productsService: ProductsServiceInterface, storeKitFacade: StoreKitFacadeInterface, localStorage: LocalStorageInterface, logger: LoggerWrapper) { + self.productsService = productsService + self.storeKitFacade = storeKitFacade + self.localStorage = localStorage + self.logger = logger + } + + func products() async throws -> [Qonversion.Product] { + guard loadedProducts.isEmpty else { + return loadedProducts + } + + let products: [Qonversion.Product] = try await productsService.products() + + do { + let productIds: [String] = products.map { $0.storeId } + let storeProducts: [StoreProductWrapper] = try await storeKitFacade.products(for: productIds) + + var resultProducts: [Qonversion.Product] = [] + + for var product in products { + guard let storeProductWrapper = storeProducts.first(where: { $0.id == product.storeId }) else { continue } + + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let storeProduct = storeProductWrapper.product { + product.enrich(storeProduct: storeProduct) + } else if let storeProduct = storeProductWrapper.oldProduct { + product.enrich(skProduct: storeProduct) + } + + resultProducts.append(product) + } + + loadedProducts = resultProducts + + return resultProducts + } catch { + logger.error(error.localizedDescription) + } + + loadedProducts = products + + return products + } + +} + +// MARK: - StoreKitFacadeDelegate + +extension ProductsManager: StoreKitFacadeDelegate { + + @available(iOS 16.4, macOS 14.4, *) + func promoPurchaseIntent(product: Product) { + #warning("Add promo purchase logic") + } + + +} diff --git a/Sources/Managers/ProductsManager/ProductsManagerInterface.swift b/Sources/Managers/ProductsManager/ProductsManagerInterface.swift new file mode 100644 index 00000000..cbdb7ab8 --- /dev/null +++ b/Sources/Managers/ProductsManager/ProductsManagerInterface.swift @@ -0,0 +1,14 @@ +// +// ProductsManagerInterface.swift +// Qonversion +// +// Created by Suren Sarkisyan on 22.04.2024. +// + +import Foundation + +protocol ProductsManagerInterface { + + func products() async throws -> [Qonversion.Product] + +} diff --git a/Sources/NetworkLayer/Error/QonversionErrorType.swift b/Sources/NetworkLayer/Error/QonversionErrorType.swift index ff865c4e..13f4eba5 100644 --- a/Sources/NetworkLayer/Error/QonversionErrorType.swift +++ b/Sources/NetworkLayer/Error/QonversionErrorType.swift @@ -20,6 +20,8 @@ enum QonversionErrorType { case deviceCreationFailed case deviceUpdateFailed case unableToSerializeDevice + case productsLoadingFailed + case storeProductsLoadingFailed case loadingRemoteConfigFailed case loadingRemoteConfigListFailed case attachingUserToRemoteConfigFailed @@ -40,6 +42,10 @@ enum QonversionErrorType { return "Device creation request failed. Unable to create the device." case .deviceUpdateFailed: return "Device update request failed. Unable to update the device." + case .productsLoadingFailed: + return "Products loading request failed." + case .storeProductsLoadingFailed: + return "Store products loading failed." case .loadingRemoteConfigFailed: return "Failed to load remote config." case .loadingRemoteConfigListFailed: diff --git a/Sources/NetworkLayer/Request/Request.swift b/Sources/NetworkLayer/Request/Request.swift index 4c1527c3..ec1f877b 100644 --- a/Sources/NetworkLayer/Request/Request.swift +++ b/Sources/NetworkLayer/Request/Request.swift @@ -19,6 +19,7 @@ enum Request : Hashable { case createDevice(userId: String, endpoint: String = "v3/device/", body: RequestBodyDict, type: RequestType = .post) case updateDevice(userId: String, endpoint: String = "v3/device/", body: RequestBodyDict, type: RequestType = .put) case appleSearchAds(userId: String, endpoint: String = "v3/appleads/", body: RequestBodyDict, type: RequestType = .post) + case getProducts(userId: String, endpoint: String = "v3/products/", type: RequestType = .get) case remoteConfig(userId: String, contextKey: String?, endpoint: String = "v3/remote-config", type: RequestType = .get) case remoteConfigList(userId: String, contextKeys: [String], includeEmptyContextKey: Bool, endpoint: String = "v3/remote-configs", type: RequestType = .get) case allRemoteConfigList(userId: String, endpoint: String = "v3/remote-configs?all_context_keys=true", type: RequestType = .get) @@ -66,6 +67,9 @@ enum Request : Hashable { case let .appleSearchAds(userId, endpoint, body, type): return defaultRequest(urlString: endpoint + userId, body: body, type: type) + + case let .getProducts(userId, endpoint, type): + return defaultRequest(urlString: endpoint + userId, body: nil, type: type) case let .remoteConfig(userId, contextKey, endpoint, type): var urlString = endpoint + "?user_id=" + userId @@ -150,6 +154,11 @@ enum Request : Hashable { hasher.combine(endpoint) hasher.combine(body) hasher.combine(type) + case let .getProducts(userId, endpoint, type): + hasher.combine("getProducts") + hasher.combine(userId) + hasher.combine(endpoint) + hasher.combine(type) case let .remoteConfig(userId, contextKey, endpoint, type): hasher.combine("remoteConfig") hasher.combine(userId) diff --git a/Sources/Qonversion.swift b/Sources/Qonversion.swift index 5c20b65e..200f1e2e 100644 --- a/Sources/Qonversion.swift +++ b/Sources/Qonversion.swift @@ -10,18 +10,11 @@ import Foundation /// An entry point to use Qonversion SDK. public final class Qonversion { - // MARK: - Private - private var userPropertiesManager: UserPropertiesManagerInterface? - private var deviceManager: DeviceManagerInterface? - private var remoteConfigManager: RemoteConfigManagerInterface? - - private init() { } - // MARK: - Public /// Use this variable to get the current initialized instance of the Qonversion SDK. /// Please, use the variable only after initializing the SDK. - /// - Returns: the current initialized instance of the ``Qonversion`` SDK + /// - Returns: the current initialized instance of the ``Qonversion/Qonversion`` SDK public static let shared = Qonversion() /// An entry point to use Qonversion SDK. Call to initialize Qonversion SDK with required and extra configs. @@ -33,6 +26,7 @@ public final class Qonversion { let assembly: QonversionAssembly = QonversionAssembly(apiKey: configuration.apiKey, userDefaults: configuration.userDefaults) Qonversion.shared.userPropertiesManager = assembly.userPropertiesManager() Qonversion.shared.deviceManager = assembly.deviceManager() + Qonversion.shared.productsManager = assembly.productsManager() Qonversion.shared.remoteConfigManager = assembly.remoteConfigManager() return Qonversion.shared @@ -163,4 +157,12 @@ public final class Qonversion { try await remoteConfigManager.detachUserFromExperiment(id: id) } + + // MARK: - Private + private var userPropertiesManager: UserPropertiesManagerInterface? + private var deviceManager: DeviceManagerInterface? + private var productsManager: ProductsManagerInterface? + private var remoteConfigManager: RemoteConfigManagerInterface? + + private init() { } } diff --git a/Sources/Services/ProductsService/ProductsService.swift b/Sources/Services/ProductsService/ProductsService.swift new file mode 100644 index 00000000..37a0d5ff --- /dev/null +++ b/Sources/Services/ProductsService/ProductsService.swift @@ -0,0 +1,31 @@ +// +// ProductsService.swift +// Qonversion +// +// Created by Suren Sarkisyan on 18.04.2024. +// + +import Foundation + +final class ProductsService: ProductsServiceInterface { + + private let requestProcessor: RequestProcessorInterface + private let internalConfig: InternalConfig + + init(requestProcessor: RequestProcessorInterface, internalConfig: InternalConfig) { + self.requestProcessor = requestProcessor + self.internalConfig = internalConfig + } + + func products() async throws -> [Qonversion.Product] { + let request = Request.getProducts(userId: internalConfig.userId) + do { + let products: [Qonversion.Product] = try await requestProcessor.process(request: request, responseType: [Qonversion.Product].self) + + return products + } catch { + throw QonversionError(type: .productsLoadingFailed, message: nil, error: error) + } + } + +} diff --git a/Sources/Services/ProductsService/ProductsServiceInterface.swift b/Sources/Services/ProductsService/ProductsServiceInterface.swift new file mode 100644 index 00000000..189ed8c4 --- /dev/null +++ b/Sources/Services/ProductsService/ProductsServiceInterface.swift @@ -0,0 +1,14 @@ +// +// ProductsServiceInterface.swift +// Qonversion +// +// Created by Suren Sarkisyan on 18.04.2024. +// + +import Foundation + +protocol ProductsServiceInterface { + + func products() async throws -> [Qonversion.Product] + +} diff --git a/Sources/Services/RemoteConfig/RemoteConfigService.swift b/Sources/Services/RemoteConfigService/RemoteConfigService.swift similarity index 100% rename from Sources/Services/RemoteConfig/RemoteConfigService.swift rename to Sources/Services/RemoteConfigService/RemoteConfigService.swift diff --git a/Sources/Services/RemoteConfig/RemoteConfigServiceInterface.swift b/Sources/Services/RemoteConfigService/RemoteConfigServiceInterface.swift similarity index 100% rename from Sources/Services/RemoteConfig/RemoteConfigServiceInterface.swift rename to Sources/Services/RemoteConfigService/RemoteConfigServiceInterface.swift diff --git a/Sources/StoreKit/StoreKitFacade.swift b/Sources/StoreKit/StoreKitFacade.swift index 4b97c9f0..53f373dd 100644 --- a/Sources/StoreKit/StoreKitFacade.swift +++ b/Sources/StoreKit/StoreKitFacade.swift @@ -13,6 +13,14 @@ class StoreKitFacade: StoreKitFacadeInterface { let storeKitOldWrapper: StoreKitOldWrapperInterface? let storeKitWrapper: StoreKitWrapperInterface? let storeKitMapper: StoreKitMapperInterface + var delegate: StoreKitFacadeDelegate? + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + var loadedProducts: [String: StoreKit.Product]? { _loadedProducts as? [String: StoreKit.Product] } + + var _loadedProducts: [String: Any] = [:] + + var loadedOldProducts: [String: SKProduct] = [:] init(storeKitOldWrapper: StoreKitOldWrapperInterface, storeKitMapper: StoreKitMapperInterface) { self.storeKitOldWrapper = storeKitOldWrapper @@ -27,7 +35,7 @@ class StoreKitFacade: StoreKitFacadeInterface { } func currentEntitlements() async -> [Qonversion.Transaction] { - guard #available(iOS 15.0, *), let storeKitWrapper = storeKitWrapper else { return [] } + guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), let storeKitWrapper = storeKitWrapper else { return [] } let transactions: [StoreKit.Transaction] = await storeKitWrapper.currentEntitlements() #warning("Map response here") @@ -94,7 +102,7 @@ class StoreKitFacade: StoreKitFacadeInterface { storeKitWrapper.finish(transaction: transaction) } - @available(iOS 15.0, *) + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) func finish(transaction: StoreKit.Transaction) async { guard let storeKitWrapper = storeKitWrapper else { return } @@ -105,27 +113,74 @@ class StoreKitFacade: StoreKitFacadeInterface { return [] } - func products(for ids: [String]) async throws -> [String] { - if #available(iOS 15.0, *) { + func products(for ids: [String]) async throws -> [StoreProductWrapper] { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { guard let storeKitWrapper = storeKitWrapper else { throw QonversionError(type: .storeKitUnavailable) } let products = try await storeKitWrapper.products(for: ids) - #warning("Map response here") - return [products.description] + products.forEach { + _loadedProducts[$0.id] = $0 + } + + return products.map { StoreProductWrapper(_product: $0, oldProduct: nil) } } else { guard let storeKitWrapper = storeKitOldWrapper else { throw QonversionError(type: .storeKitUnavailable) } return try await withCheckedThrowingContinuation { continuation in - storeKitWrapper.products(for: ids, completion: { response, error in + storeKitWrapper.products(for: ids, completion: { [weak self] response, error in + guard let self else { return } + if let error { - #warning("Handle error here") - continuation.resume(throwing: QonversionError(type: .critical)) + continuation.resume(throwing: QonversionError(type: .storeProductsLoadingFailed, error: error)) } else { - #warning("Map response here") - continuation.resume(returning: [""]) + guard let response else { + return continuation.resume(throwing: QonversionError(type: .storeProductsLoadingFailed)) + } + + response.products.forEach { + self.loadedOldProducts[$0.productIdentifier] = $0 + } + + let products: [StoreProductWrapper] = response.products.map { StoreProductWrapper(_product: nil, oldProduct: $0) } + continuation.resume(returning: products) } }) } } } } + +// MARK: - StoreKitWrapperDelegate + +extension StoreKitFacade: StoreKitWrapperDelegate { + + @available(iOS 16.4, macOS 14.4, *) + func promoPurchaseIntent(product: Product) { + delegate?.promoPurchaseIntent(product: product) + } +} + +// MARK: - StoreKitOldWrapperDelegate + +extension StoreKitFacade: StoreKitOldWrapperDelegate { + + func handle(productsResponse: SKProductsResponse) { + + } + + func handle(restoreTransactionsError: any Error) { + + } + + func shouldAdd(storePayment: SKPayment, for product: SKProduct) -> Bool { + return true + } + + func handle(productsRequestError: any Error) { + + } + + func updated(transactions: [SKPaymentTransaction]) { + + } +} diff --git a/Sources/StoreKit/StoreKitFacadeDelegate.swift b/Sources/StoreKit/StoreKitFacadeDelegate.swift new file mode 100644 index 00000000..3e82308e --- /dev/null +++ b/Sources/StoreKit/StoreKitFacadeDelegate.swift @@ -0,0 +1,15 @@ +// +// StoreKitFacadeDelegate.swift +// Qonversion +// +// Created by Suren Sarkisyan on 24.04.2024. +// + +import Foundation +import StoreKit + +protocol StoreKitFacadeDelegate { + + @available(iOS 16.4, macOS 14.4, *) + func promoPurchaseIntent(product: Product) +} diff --git a/Sources/StoreKit/StoreKitFacadeInterface.swift b/Sources/StoreKit/StoreKitFacadeInterface.swift index bc2c3f4c..3bdb0bf4 100644 --- a/Sources/StoreKit/StoreKitFacadeInterface.swift +++ b/Sources/StoreKit/StoreKitFacadeInterface.swift @@ -9,7 +9,7 @@ import StoreKit protocol StoreKitFacadeInterface { #warning("replace all return types") - func products(for ids:[String]) async throws -> [String] + func products(for ids:[String]) async throws -> [StoreProductWrapper] func currentEntitlements() async -> [Qonversion.Transaction] @@ -17,7 +17,7 @@ protocol StoreKitFacadeInterface { func finish(transaction: SKPaymentTransaction) - @available(iOS 15.0, *) + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) func finish(transaction: StoreKit.Transaction) async func subscribe() async -> [Qonversion.Transaction] diff --git a/Sources/StoreKit/StoreKitOldWrapper.swift b/Sources/StoreKit/StoreKitOldWrapper.swift index aad47424..2ad747ac 100644 --- a/Sources/StoreKit/StoreKitOldWrapper.swift +++ b/Sources/StoreKit/StoreKitOldWrapper.swift @@ -10,16 +10,15 @@ import StoreKit class StoreKitOldWrapper: NSObject, StoreKitOldWrapperInterface { - let delegate: StoreKitOldWrapperDelegate let paymentQueue: SKPaymentQueue + var delegate: StoreKitOldWrapperDelegate? var productsRequest: SKProductsRequest? var productsCompletions: [SKProductsRequest: StoreKitOldProductsCompletion] = [:] var purchaseCompletions: [SKPayment: StoreKitOldTransactionsCompletion] = [:] var restoreCompletions: [StoreKitOldTransactionsCompletion] = [] - init(delegate: StoreKitOldWrapperDelegate, paymentQueue: SKPaymentQueue) { - self.delegate = delegate + init(paymentQueue: SKPaymentQueue) { self.paymentQueue = paymentQueue super.init() @@ -47,7 +46,7 @@ class StoreKitOldWrapper: NSObject, StoreKitOldWrapperInterface { paymentQueue.restoreCompletedTransactions() } - @available(iOS 14.0, *) + @available(iOS 14.0, visionOS 1.0, *) func presentCodeRedemptionSheet() { paymentQueue.presentCodeRedemptionSheet() } @@ -78,20 +77,21 @@ extension StoreKitOldWrapper: SKPaymentTransactionObserver { firePurchaseCompletions(with: transactions) if purchaseCompletions.isEmpty { - delegate.updated(transactions: transactions) + delegate?.updated(transactions: transactions) } } func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { guard restoreCompletions.count > 0 else { - return delegate.handle(restoreTransactionsError: error) + delegate?.handle(restoreTransactionsError: error) + return } fireRestoreCompletions(with: [], error: error) } func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - return delegate.shouldAdd(storePayment: payment, for: product) + return delegate?.shouldAdd(storePayment: payment, for: product) ?? true } } @@ -102,7 +102,8 @@ extension StoreKitOldWrapper: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { guard let completion: StoreKitOldProductsCompletion = productsCompletions[request] else { - return delegate.handle(productsResponse: response) + delegate?.handle(productsResponse: response) + return } completion(response, nil) @@ -111,7 +112,7 @@ extension StoreKitOldWrapper: SKProductsRequestDelegate { func request(_ request: SKRequest, didFailWithError error: Error) { guard request is SKProductsRequest else { return } - delegate.handle(productsRequestError: error) + delegate?.handle(productsRequestError: error) } } diff --git a/Sources/StoreKit/StoreKitOldWrapperInterface.swift b/Sources/StoreKit/StoreKitOldWrapperInterface.swift index ae05c253..ca6e7703 100644 --- a/Sources/StoreKit/StoreKitOldWrapperInterface.swift +++ b/Sources/StoreKit/StoreKitOldWrapperInterface.swift @@ -16,7 +16,7 @@ protocol StoreKitOldWrapperInterface { func restore(with completion: @escaping StoreKitOldTransactionsCompletion) - @available(iOS 14.0, *) + @available(iOS 14.0, visionOS 1.0, *) func presentCodeRedemptionSheet() func purchase(product: SKProduct, completion: @escaping StoreKitOldTransactionsCompletion) diff --git a/Sources/StoreKit/StoreKitWrapper.swift b/Sources/StoreKit/StoreKitWrapper.swift index 0964e79a..2b453582 100644 --- a/Sources/StoreKit/StoreKitWrapper.swift +++ b/Sources/StoreKit/StoreKitWrapper.swift @@ -8,14 +8,10 @@ import Foundation import StoreKit -@available(iOS 15.0, *) +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) final class StoreKitWrapper: StoreKitWrapperInterface { - let delegate: StoreKitWrapperDelegate? - - init(delegate: StoreKitWrapperDelegate? = nil) { - self.delegate = delegate - } + var delegate: StoreKitWrapperDelegate? func products(for ids:[String]) async throws -> [StoreKit.Product] { let products: [StoreKit.Product] = try await Product.products(for: ids) @@ -68,7 +64,7 @@ final class StoreKitWrapper: StoreKitWrapperInterface { return await fetchTransactions(for: StoreKit.Transaction.updates) } - @available(iOS 16.4, *) + @available(iOS 16.4, macOS 14.4, *) func subscribeToPromoPurchases() { Task.detached { for await purchaseIntent in PurchaseIntent.intents { @@ -78,7 +74,7 @@ final class StoreKitWrapper: StoreKitWrapperInterface { } - @available(iOS 16.0, *) + @available(iOS 16.0, visionOS 1.0, *) func presentOfferCodeRedeemSheet(in scene: UIWindowScene) async throws { try await AppStore.presentOfferCodeRedeemSheet(in: scene) } diff --git a/Sources/StoreKit/StoreKitWrapperDelegate.swift b/Sources/StoreKit/StoreKitWrapperDelegate.swift index 8da176ea..dc3d44d2 100644 --- a/Sources/StoreKit/StoreKitWrapperDelegate.swift +++ b/Sources/StoreKit/StoreKitWrapperDelegate.swift @@ -8,8 +8,9 @@ import Foundation import StoreKit -@available(iOS 15.0, *) +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) protocol StoreKitWrapperDelegate { + @available(iOS 16.4, macOS 14.4, *) func promoPurchaseIntent(product: Product) } diff --git a/Sources/TemporaryFactory.swift b/Sources/TemporaryFactory.swift index 70e2bea5..cca04c58 100644 --- a/Sources/TemporaryFactory.swift +++ b/Sources/TemporaryFactory.swift @@ -44,7 +44,7 @@ final public class TemporaryFactory: StoreKitWrapperDelegate, StoreKitOldWrapper public init() { if #available(iOS 15.0, *) { - self.facade = StoreKitFacade(storeKitOldWrapper: StoreKitOldWrapper(delegate: self, paymentQueue: SKPaymentQueue.default()), storeKitMapper: StoreKitMapper()) + self.facade = StoreKitFacade(storeKitOldWrapper: StoreKitOldWrapper(paymentQueue: SKPaymentQueue.default()), storeKitMapper: StoreKitMapper()) } else { // Fallback on earlier versions } diff --git a/Sources/Utils/Utils.swift b/Sources/Utils/Utils.swift index b56405e3..54e1d289 100644 --- a/Sources/Utils/Utils.swift +++ b/Sources/Utils/Utils.swift @@ -6,6 +6,7 @@ // import Foundation +import StoreKit typealias Codable = Decodable & Encodable @@ -35,6 +36,28 @@ extension Locale.Currency { } } +extension SKProduct { + + func displayPrice() -> String? { + return format(price: price, priceLcale: priceLocale) + } +} + +extension SKProductDiscount { + + func displayPrice() -> String? { + return format(price: price, priceLcale: priceLocale) + } +} + +private func format(price: NSDecimalNumber, priceLcale: Locale) -> String? { + let formatter = NumberFormatter() + formatter.formatterBehavior = .behavior10_4 + formatter.locale = priceLcale + + return formatter.string(for: price) +} + // The below decoding implementations are taken from https://adamrackis.dev/blog/swift-codable-any struct JSONCodingKeys: CodingKey { var stringValue: String