diff --git a/Adapty.podspec b/Adapty.podspec index 6533c34a2..5fbabd6d4 100644 --- a/Adapty.podspec +++ b/Adapty.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'Adapty' - s.version = '3.11.0' + s.version = '3.12.0' s.summary = 'Adapty SDK for iOS.' s.description = <<-DESC diff --git a/AdaptyPlugin.podspec b/AdaptyPlugin.podspec index 34e2e6758..cd51283ae 100644 --- a/AdaptyPlugin.podspec +++ b/AdaptyPlugin.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'AdaptyPlugin' - s.version = '3.11.0' + s.version = '3.12.0' s.summary = 'Common files for cross-platform SDKs Adapty' s.description = <<-DESC diff --git a/AdaptyUI.podspec b/AdaptyUI.podspec index 6134b5235..ccf6c79d5 100644 --- a/AdaptyUI.podspec +++ b/AdaptyUI.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'AdaptyUI' - s.version = '3.11.0' + s.version = '3.12.0' s.summary = 'Adapty SDK for iOS.' s.description = <<-DESC diff --git a/AdaptyUI/AdaptyUI+Onboardings.swift b/AdaptyUI/AdaptyUI+Onboardings.swift index 3bcf99696..2d70c61d5 100644 --- a/AdaptyUI/AdaptyUI+Onboardings.swift +++ b/AdaptyUI/AdaptyUI+Onboardings.swift @@ -20,13 +20,15 @@ public extension AdaptyUI { init( logId: String, - onboarding: AdaptyOnboarding + onboarding: AdaptyOnboarding, + inspectWebView: Bool ) { Log.ui.verbose("#\(logId)# init onboarding: \(onboarding.placement.id)") self.viewModel = AdaptyOnboardingViewModel( logId: logId, - onboarding: onboarding + onboarding: onboarding, + inspectWebView: inspectWebView ) } } @@ -47,7 +49,8 @@ public extension AdaptyUI { return OnboardingConfiguration( logId: Log.stamp, - onboarding: onboarding + onboarding: onboarding, + inspectWebView: false ) } @@ -71,4 +74,26 @@ public extension AdaptyUI { } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +@MainActor +package extension AdaptyUI { + static func getOnboardingConfiguration( + forOnboarding onboarding: AdaptyOnboarding, + inspectWebView: Bool + ) throws -> OnboardingConfiguration { + guard AdaptyUI.isActivated else { + let err = AdaptyUIError.adaptyNotActivated + Log.ui.error("AdaptyUI getViewConfiguration error: \(err)") + + throw err + } + + return OnboardingConfiguration( + logId: Log.stamp, + onboarding: onboarding, + inspectWebView: inspectWebView + ) + } +} + #endif diff --git a/AdaptyUI/Cache/KingFisher/General/KingfisherManager.swift b/AdaptyUI/Cache/KingFisher/General/KingfisherManager.swift index 49106db3c..859e52b5e 100644 --- a/AdaptyUI/Cache/KingFisher/General/KingfisherManager.swift +++ b/AdaptyUI/Cache/KingFisher/General/KingfisherManager.swift @@ -24,7 +24,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. - import Foundation #if os(macOS) import AppKit @@ -37,16 +36,16 @@ import UIKit /// This block type is used to monitor the progress of data being downloaded. It takes two parameters: /// /// 1. `receivedSize`: The size of the data received in the current response. -/// 2. `expectedSize`: The total expected data length from the response's "Content-Length" header. If the expected +/// 2. `expectedSize`: The total expected data length from the response's "Content-Length" header. If the expected /// length is not available, this block will not be called. /// -/// You can use this progress block to track the download progress and update user interfaces or perform additional +/// You can use this progress block to track the download progress and update user interfaces or perform additional /// actions based on the progress. /// /// - Parameters: /// - receivedSize: The size of the data received. /// - expectedSize: The expected total data length from the "Content-Length" header. -typealias DownloadProgressBlock = ((_ receivedSize: Int64, _ totalSize: Int64) -> Void) +typealias DownloadProgressBlock = (_ receivedSize: Int64, _ totalSize: Int64) -> Void /// Represents the result of a Kingfisher image retrieval task. /// @@ -70,14 +69,14 @@ struct RetrieveImageResult: Sendable { /// When an alternative source loading occurs, the ``source`` will represent the replacement loading target, while the /// ``originalSource`` will retain the initial ``source`` that initiated the image loading process. let originalSource: Source - + /// Retrieves the data associated with this result. /// - /// When this result is obtained from a network download (when `cacheType == .none`), calling this method returns + /// When this result is obtained from a network download (when `cacheType == .none`), calling this method returns /// the downloaded data. If the result is from the cache, it serializes the image using the specified cache /// serializer from the loading options and returns the result. /// - /// - Note: Retrieving this data can be a time-consuming operation, so it is advisable to store it if you need to + /// - Note: Retrieving this data can be a time-consuming operation, so it is advisable to store it if you need to /// use it multiple times and avoid frequent calls to this method. let data: @Sendable () -> Data? } @@ -85,7 +84,6 @@ struct RetrieveImageResult: Sendable { /// A structure that stores related information about a ``KingfisherError``. It provides contextual information /// to facilitate the identification of the error. struct PropagationError: Sendable { - /// The ``Source`` to which current `error` is bound. let source: Source @@ -93,29 +91,28 @@ struct PropagationError: Sendable { let error: KingfisherError } -/// The block type used for handling updates during the downloading task. +/// The block type used for handling updates during the downloading task. /// /// The `newTask` parameter represents the updated task for the image loading process. It is `nil` if the image loading /// doesn't involve a downloading process. When an image download is initiated, this value will contain the actual /// ``DownloadTask`` instance, allowing you to retain it or cancel it later if necessary. -typealias DownloadTaskUpdatedBlock = (@Sendable (_ newTask: DownloadTask?) -> Void) +typealias DownloadTaskUpdatedBlock = @Sendable (_ newTask: DownloadTask?) -> Void -/// The main manager class of Kingfisher. It connects the Kingfisher downloader and cache to offer a set of convenient +/// The main manager class of Kingfisher. It connects the Kingfisher downloader and cache to offer a set of convenient /// methods for working with Kingfisher tasks. /// /// You can utilize this class to retrieve an image via a specified URL from the web or cache. class KingfisherManager: @unchecked Sendable { - private let propertyQueue = DispatchQueue(label: "com.onevcat.Kingfisher.KingfisherManagerPropertyQueue") - + /// Represents a shared manager used across Kingfisher. /// Use this instance for getting or storing images with Kingfisher. static let shared = KingfisherManager() - // Mark: Properties - + // MARK: Properties + private var _cache: ImageCache - + /// The ``ImageCache`` utilized by this manager, which defaults to ``ImageCache/default``. /// /// If a cache is specified in ``KingfisherManager/defaultOptions`` or ``KingfisherOptionsInfoItem/targetCache(_:)``, @@ -124,9 +121,9 @@ class KingfisherManager: @unchecked Sendable { get { propertyQueue.sync { _cache } } set { propertyQueue.sync { _cache = newValue } } } - + private var _downloader: ImageDownloader - + /// The ``ImageDownloader`` utilized by this manager, which defaults to ``ImageDownloader/default``. /// /// If a downloader is specified in ``KingfisherManager/defaultOptions`` or ``KingfisherOptionsInfoItem/downloader(_:)``, @@ -136,7 +133,7 @@ class KingfisherManager: @unchecked Sendable { get { propertyQueue.sync { _downloader } } set { propertyQueue.sync { _downloader = newValue } } } - + /// The default options used by the ``KingfisherManager`` instance. /// /// These options are utilized in Kingfisher manager-related methods, as well as all view extension methods. @@ -144,14 +141,14 @@ class KingfisherManager: @unchecked Sendable { /// /// Per-image options will override the default ones if there is a conflict. var defaultOptions = KingfisherOptionsInfo.empty - + // Use `defaultOptions` to overwrite the `downloader` and `cache`. private var currentDefaultOptions: KingfisherOptionsInfo { return [.downloader(downloader), .targetCache(cache)] + defaultOptions } private let processingQueue: CallbackQueue - + private convenience init() { self.init(downloader: .default, cache: .default) } @@ -177,12 +174,12 @@ class KingfisherManager: @unchecked Sendable { /// - Parameters: /// - resource: The ``Resource`` object defining data information, such as a key or URL. /// - options: Options to use when creating the image. - /// - progressBlock: Called when the image download progress is updated. This block is invoked only if the response + /// - progressBlock: Called when the image download progress is updated. This block is invoked only if the response /// contains an `expectedContentLength` and always runs on the main queue. /// - downloadTaskUpdated: Called when a new image download task is created for the current image retrieval. This /// typically occurs when an alternative source is used to replace the original (failed) task. You can update your /// reference to the ``DownloadTask`` if you want to manually invoke ``DownloadTask/cancel()`` on the new task. - /// - completionHandler: Called when the image retrieval and setting are completed. This completion handler is + /// - completionHandler: Called when the image retrieval and setting are completed. This completion handler is /// invoked from the `options.callbackQueue`. If not specified, the main queue is used. /// /// - Returns: A task representing the image download. If a download task is initiated for a ``Source/network(_:)`` resource, @@ -244,7 +241,8 @@ class KingfisherManager: @unchecked Sendable { options: info, progressBlock: progressBlock, downloadTaskUpdated: downloadTaskUpdated, - completionHandler: completionHandler) + completionHandler: completionHandler + ) } func retrieveImage( @@ -263,7 +261,8 @@ class KingfisherManager: @unchecked Sendable { options: info, downloadTaskUpdated: downloadTaskUpdated, progressiveImageSetter: nil, - completionHandler: completionHandler) + completionHandler: completionHandler + ) } func retrieveImage( @@ -276,7 +275,7 @@ class KingfisherManager: @unchecked Sendable { { var options = options let retryStrategy = options.retryStrategy - + if let provider = ImageProgressiveProvider(options: options, refresh: { image in guard let setter = progressiveImageSetter else { return @@ -298,15 +297,15 @@ class KingfisherManager: @unchecked Sendable { $0.onShouldApply = checker } } - + let retrievingContext = RetrievingContext(options: options, originalSource: source) @Sendable func startNewRetrieveTask( with source: Source, retryContext: RetryContext?, - downloadTaskUpdated: DownloadTaskUpdatedBlock? - ) { - let newTask = self.retrieveImage(with: source, context: retrievingContext) { result in + downloadTaskUpdated: DownloadTaskUpdatedBlock?) + { + let newTask = retrieveImage(with: source, context: retrievingContext) { result in handler(currentSource: source, retryContext: retryContext, result: result) } downloadTaskUpdated?(newTask) @@ -333,7 +332,7 @@ class KingfisherManager: @unchecked Sendable { retrievingContext.appendError(error, to: source) startNewRetrieveTask(with: nextSource, retryContext: retryContext, downloadTaskUpdated: downloadTaskUpdated) } else { - // No other alternative source. Finish with error. + // No other alternative source. finish with error. if retrievingContext.propagationErrors.isEmpty { completionHandler?(.failure(error)) } else { @@ -349,8 +348,8 @@ class KingfisherManager: @unchecked Sendable { @Sendable func handler( currentSource: Source, retryContext: RetryContext?, - result: (Result) - ) -> Void { + result: Result) + { switch result { case .success: completionHandler?(result) @@ -374,14 +373,13 @@ class KingfisherManager: @unchecked Sendable { return retrieveImage( with: source, - context: retrievingContext) - { + context: retrievingContext + ) { result in handler(currentSource: source, retryContext: nil, result: result) } - } - + private func retrieveImage( with source: Source, context: RetrievingContext, @@ -392,28 +390,31 @@ class KingfisherManager: @unchecked Sendable { return loadAndCacheImage( source: source, context: context, - completionHandler: completionHandler)?.value - + completionHandler: completionHandler + )?.value + } else { let loadedFromCache = retrieveImageFromCache( source: source, context: context, - completionHandler: completionHandler) - + completionHandler: completionHandler + ) + if loadedFromCache { return nil } - + if options.onlyFromCache { let error = KingfisherError.cacheError(reason: .imageNotExisting(key: source.cacheKey)) completionHandler?(.failure(error)) return nil } - + return loadAndCacheImage( source: source, context: context, - completionHandler: completionHandler)?.value + completionHandler: completionHandler + )?.value } } @@ -422,7 +423,7 @@ class KingfisherManager: @unchecked Sendable { options: KingfisherParsedOptionsInfo, completionHandler: (@Sendable (Result) -> Void)?) { - guard let completionHandler = completionHandler else { return } + guard let completionHandler = completionHandler else { return } provider.data { result in switch result { case .success(let data): @@ -449,7 +450,6 @@ class KingfisherManager: @unchecked Sendable { reason: .dataProviderError(provider: provider, error: error)) completionHandler(.failure(error)) } - } } } @@ -459,31 +459,31 @@ class KingfisherManager: @unchecked Sendable { options: KingfisherParsedOptionsInfo, context: RetrievingContext, result: Result, - completionHandler: (@Sendable (Result) -> Void)? - ) + completionHandler: (@Sendable (Result) -> Void)?) { switch result { case .success(let value): let needToCacheOriginalImage = options.cacheOriginalImage && - options.processor != DefaultImageProcessor.default + options.processor != DefaultImageProcessor.default let coordinator = CacheCallbackCoordinator( - shouldWaitForCache: options.waitForCache, shouldCacheOriginal: needToCacheOriginalImage) + shouldWaitForCache: options.waitForCache, shouldCacheOriginal: needToCacheOriginalImage + ) let result = RetrieveImageResult( image: options.imageModifier?.modify(value.image) ?? value.image, cacheType: .none, source: source, originalSource: context.originalSource, - data: { value.originalData } + data: { value.originalData } ) // Add image to cache. - let targetCache = options.targetCache ?? self.cache + let targetCache = options.targetCache ?? cache targetCache.store( value.image, original: value.originalData, forKey: source.cacheKey, options: options, - toDisk: !options.cacheMemoryOnly) - { + toDisk: !options.cacheMemoryOnly + ) { _ in coordinator.apply(.cachingImage) { completionHandler?(.success(result)) @@ -498,8 +498,8 @@ class KingfisherManager: @unchecked Sendable { value.originalData, forKey: source.cacheKey, processorIdentifier: DefaultImageProcessor.default.identifier, - expiration: options.diskCacheExpiration) - { + expiration: options.diskCacheExpiration + ) { _ in coordinator.apply(.cachingOriginalImage) { completionHandler?(.success(result)) @@ -540,9 +540,8 @@ class KingfisherManager: @unchecked Sendable { with: resource.downloadURL, options: options, completionHandler: _cacheImage ) - - // The code below is neat, but it fails the Swift 5.2 compiler with a runtime crash when - // `BUILD_LIBRARY_FOR_DISTRIBUTION` is turned on. I believe it is a bug in the compiler. + // The code below is neat, but it fails the Swift 5.2 compiler with a runtime crash when + // `BUILD_LIBRARY_FOR_DISTRIBUTION` is turned on. I believe it is a bug in the compiler. // Let's fallback to a traditional style before it can be fixed in Swift. // // https://github.com/onevcat/Kingfisher/issues/1436 @@ -560,13 +559,13 @@ class KingfisherManager: @unchecked Sendable { return .dataProviding } } - + /// Retrieves an image from either memory or disk cache. /// /// - Parameters: /// - source: The target source from which to retrieve the image. /// - key: The key to use for caching the image. - /// - url: The image request URL. This is not used when retrieving an image from the cache; it is solely used for + /// - url: The image request URL. This is not used when retrieving an image from the cache; it is solely used for /// compatibility with ``RetrieveImageResult`` callbacks. /// - options: Options on how to retrieve the image from the image cache. /// - completionHandler: Called when the image retrieval is complete, either with a successful @@ -590,14 +589,15 @@ class KingfisherManager: @unchecked Sendable { let targetCache = options.targetCache ?? cache let key = source.cacheKey let targetImageCached = targetCache.imageCachedType( - forKey: key, processorIdentifier: options.processor.identifier) - + forKey: key, processorIdentifier: options.processor.identifier + ) + let validCache = targetImageCached.cached && (options.fromMemoryCacheOrRefresh == false || targetImageCached == .memory) if validCache { targetCache.retrieveImage(forKey: key, options: options) { result in guard let completionHandler = completionHandler else { return } - + // TODO: Optimize it when we can use async across all the project. @Sendable func checkResultImageAndCallback(_ inputImage: KFCrossPlatformImage) { var image = inputImage @@ -620,14 +620,14 @@ class KingfisherManager: @unchecked Sendable { } completionHandler(value) } - + result.match { cacheResult in options.callbackQueue.execute { guard let image = cacheResult.image else { completionHandler(.failure(KingfisherError.cacheError(reason: .imageNotExisting(key: key)))) return } - + if options.cacheSerializer.originalDataUsed { let processor = options.processor (options.processingQueue ?? self.processingQueue).execute { @@ -646,7 +646,7 @@ class KingfisherManager: @unchecked Sendable { checkResultImageAndCallback(image) } } - } onFailure: { error in + } onFailure: { _ in options.callbackQueue.execute { completionHandler(.failure(KingfisherError.cacheError(reason: .imageNotExisting(key: key)))) } @@ -664,20 +664,20 @@ class KingfisherManager: @unchecked Sendable { // Check whether the unprocessed image existing or not. let originalImageCacheType = originalCache.imageCachedType( - forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier) + forKey: key, processorIdentifier: DefaultImageProcessor.default.identifier + ) let canAcceptDiskCache = !options.fromMemoryCacheOrRefresh - + let canUseOriginalImageCache = (canAcceptDiskCache && originalImageCacheType.cached) || (!canAcceptDiskCache && originalImageCacheType == .memory) - + if canUseOriginalImageCache { // Now we are ready to get found the original image from cache. We need the unprocessed image, so remove // any processor from options first. var optionsWithoutProcessor = options optionsWithoutProcessor.processor = DefaultImageProcessor.default originalCache.retrieveImage(forKey: key, options: optionsWithoutProcessor) { result in - result.match( onSuccess: { cacheResult in guard let image = cacheResult.image else { @@ -699,7 +699,8 @@ class KingfisherManager: @unchecked Sendable { cacheOptions.callbackQueue = .untouch let coordinator = CacheCallbackCoordinator( - shouldWaitForCache: options.waitForCache, shouldCacheOriginal: false) + shouldWaitForCache: options.waitForCache, shouldCacheOriginal: false + ) let image = options.imageModifier?.modify(processedImage) ?? processedImage let result = RetrieveImageResult( @@ -714,8 +715,8 @@ class KingfisherManager: @unchecked Sendable { processedImage, forKey: key, options: cacheOptions, - toDisk: !options.cacheMemoryOnly) - { + toDisk: !options.cacheMemoryOnly + ) { _ in coordinator.apply(.cachingImage) { options.callbackQueue.execute { completionHandler?(.success(result)) } @@ -747,7 +748,6 @@ class KingfisherManager: @unchecked Sendable { // Concurrency extension KingfisherManager { - /// Retrieves an image from a specified resource. /// /// - Parameters: @@ -766,8 +766,7 @@ extension KingfisherManager { func retrieveImage( with resource: any Resource, options: KingfisherOptionsInfo? = nil, - progressBlock: DownloadProgressBlock? = nil - ) async throws -> RetrieveImageResult + progressBlock: DownloadProgressBlock? = nil) async throws -> RetrieveImageResult { try await retrieveImage( with: resource.convertToSource(), @@ -775,7 +774,7 @@ extension KingfisherManager { progressBlock: progressBlock ) } - + /// Retrieves an image from a specified source. /// /// - Parameters: @@ -794,8 +793,7 @@ extension KingfisherManager { func retrieveImage( with source: Source, options: KingfisherOptionsInfo? = nil, - progressBlock: DownloadProgressBlock? = nil - ) async throws -> RetrieveImageResult + progressBlock: DownloadProgressBlock? = nil) async throws -> RetrieveImageResult { let options = currentDefaultOptions + (options ?? .empty) let info = KingfisherParsedOptionsInfo(options) @@ -805,12 +803,11 @@ extension KingfisherManager { progressBlock: progressBlock ) } - + func retrieveImage( with source: Source, options: KingfisherParsedOptionsInfo, - progressBlock: DownloadProgressBlock? = nil - ) async throws -> RetrieveImageResult + progressBlock: DownloadProgressBlock? = nil) async throws -> RetrieveImageResult { var info = options if let block = progressBlock { @@ -822,13 +819,12 @@ extension KingfisherManager { progressiveImageSetter: nil ) } - + func retrieveImage( with source: Source, options: KingfisherParsedOptionsInfo, progressiveImageSetter: ((KFCrossPlatformImage?) -> Void)? = nil, - referenceTaskIdentifierChecker: (() -> Bool)? = nil - ) async throws -> RetrieveImageResult + referenceTaskIdentifierChecker: (() -> Bool)? = nil) async throws -> RetrieveImageResult { let task = CancellationDownloadTask() return try await withTaskCancellationHandler { @@ -864,9 +860,8 @@ extension KingfisherManager { } class RetrievingContext: @unchecked Sendable { - private let propertyQueue = DispatchQueue(label: "com.onevcat.Kingfisher.RetrievingContextPropertyQueue") - + private var _options: KingfisherParsedOptionsInfo var options: KingfisherParsedOptionsInfo { get { propertyQueue.sync { _options } } @@ -887,10 +882,10 @@ class RetrievingContext: @unchecked Sendable { return nil } let nextSource = alternativeSources.removeFirst() - + localOptions.alternativeSources = alternativeSources options = localOptions - + return nextSource } @@ -903,7 +898,6 @@ class RetrievingContext: @unchecked Sendable { } class CacheCallbackCoordinator: @unchecked Sendable { - enum State { case idle case imageCached @@ -931,7 +925,7 @@ class CacheCallbackCoordinator: @unchecked Sendable { self.shouldWaitForCache = shouldWaitForCache self.shouldCacheOriginal = shouldCacheOriginal let stateQueueName = "com.onevcat.Kingfisher.CacheCallbackCoordinator.stateQueue.\(UUID().uuidString)" - self.stateQueue = DispatchQueue(label: stateQueueName) + stateQueue = DispatchQueue(label: stateQueueName) } func apply(_ action: Action, trigger: () -> Void) { @@ -945,6 +939,7 @@ class CacheCallbackCoordinator: @unchecked Sendable { state = .done trigger() } + case (.idle, .cachingImage): if shouldCacheOriginal { state = .imageCached @@ -952,6 +947,7 @@ class CacheCallbackCoordinator: @unchecked Sendable { state = .done trigger() } + case (.idle, .cachingOriginalImage): state = .originalImageCached diff --git a/AdaptyUI/Crossplatform/PaywallView.swift b/AdaptyUI/Crossplatform/PaywallView.swift index 085d8af1c..b904a9f92 100644 --- a/AdaptyUI/Crossplatform/PaywallView.swift +++ b/AdaptyUI/Crossplatform/PaywallView.swift @@ -25,14 +25,26 @@ public extension AdaptyUI { #if canImport(UIKit) +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +package extension AdaptyPaywallUIView { + func toAdaptyUIView() -> AdaptyUI.PaywallView { + AdaptyUI.PaywallView( + id: id, + templateId: configuration.paywallViewModel.viewConfiguration.templateId, + placementId: configuration.paywallViewModel.paywall.placementId, + variationId: configuration.paywallViewModel.paywall.variationId + ) + } +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) public extension AdaptyPaywallController { func toAdaptyUIView() -> AdaptyUI.PaywallView { AdaptyUI.PaywallView( - id: id.uuidString, - templateId: paywallConfiguration.paywallViewModel.viewConfiguration.templateId, - placementId: paywallConfiguration.paywallViewModel.paywall.placementId, - variationId: paywallConfiguration.paywallViewModel.paywall.variationId + id: id, + templateId: configuration.paywallViewModel.viewConfiguration.templateId, + placementId: configuration.paywallViewModel.paywall.placementId, + variationId: configuration.paywallViewModel.paywall.variationId ) } } diff --git a/AdaptyUI/Crossplatform/Plugin+Error.swift b/AdaptyUI/Crossplatform/Plugin+Error.swift index 63820719a..a2a60cf12 100644 --- a/AdaptyUI/Crossplatform/Plugin+Error.swift +++ b/AdaptyUI/Crossplatform/Plugin+Error.swift @@ -35,10 +35,10 @@ extension AdaptyUI.PluginError: CustomAdaptyError { public var adaptyErrorCode: AdaptyError.ErrorCode { switch self { - case .viewNotFound: return AdaptyError.ErrorCode.wrongParam - case .viewAlreadyPresented: return AdaptyError.ErrorCode.wrongParam - case .viewPresentationError: return AdaptyError.ErrorCode.wrongParam - case .delegateIsNotRegestired: return AdaptyError.ErrorCode.unknown + case .viewNotFound: .wrongParam + case .viewAlreadyPresented: .wrongParam + case .viewPresentationError: .wrongParam + case .delegateIsNotRegestired: .unknown } } diff --git a/AdaptyUI/Crossplatform/Plugin.swift b/AdaptyUI/Crossplatform/Plugin.swift index f990a97bf..854eb94ac 100644 --- a/AdaptyUI/Crossplatform/Plugin.swift +++ b/AdaptyUI/Crossplatform/Plugin.swift @@ -33,6 +33,22 @@ fileprivate extension UIWindow { } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +package enum AdaptyUIViewPresentationStyle: String, Codable { + case fullScreen = "full_screen" + case pageSheet = "page_sheet" +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension AdaptyUIViewPresentationStyle { + var uiKitPresentationStyle: UIModalPresentationStyle { + switch self { + case .fullScreen: .overFullScreen + case .pageSheet: .pageSheet + } + } +} + #endif @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) @@ -98,13 +114,14 @@ package extension AdaptyUI { ) let vc = try AdaptyUI.paywallControllerWithUniversalDelegate(configuration) - cachePaywallController(vc, id: vc.id.uuidString) + cachePaywallController(vc, id: vc.id) return vc.toAdaptyUIView() } #endif package static func presentPaywallView( - viewId: String + viewId: String, + presentationStyle: AdaptyUIViewPresentationStyle? ) async throws { #if canImport(UIKit) guard let vc = cachedPaywallController(viewId) else { @@ -120,7 +137,7 @@ package extension AdaptyUI { } vc.modalPresentationCapturesStatusBarAppearance = true - vc.modalPresentationStyle = .overFullScreen + vc.modalPresentationStyle = presentationStyle?.uiKitPresentationStyle ?? .overFullScreen await withCheckedContinuation { continuation in rootVC.present(vc, animated: true) { @@ -205,7 +222,8 @@ package extension AdaptyUI.Plugin { } static func presentOnboardingView( - viewId: String + viewId: String, + presentationStyle: AdaptyUIViewPresentationStyle? ) async throws { #if canImport(UIKit) guard let vc = cachedOnboardingController(viewId) else { @@ -221,8 +239,7 @@ package extension AdaptyUI.Plugin { } vc.modalPresentationCapturesStatusBarAppearance = true -// vc.modalPresentationStyle = .overFullScreen - vc.modalPresentationStyle = .formSheet // TODO: add param + vc.modalPresentationStyle = presentationStyle?.uiKitPresentationStyle ?? .overFullScreen await withCheckedContinuation { continuation in rootVC.present(vc, animated: true) { diff --git a/AdaptyUI/Error/AdaptyUIError.swift b/AdaptyUI/Error/AdaptyUIError.swift index 5ac37a5dd..e06d0d828 100644 --- a/AdaptyUI/Error/AdaptyUIError.swift +++ b/AdaptyUI/Error/AdaptyUIError.swift @@ -21,7 +21,7 @@ public enum AdaptyUIError: Error { case webKit(Error) } -extension AdaptyUIError { +public extension AdaptyUIError { static let AdaptyUIErrorDomain = "AdaptyUIErrorDomain" enum Code: Int { @@ -35,6 +35,8 @@ extension AdaptyUIError { case wrongComponentType = 4103 case webKit = 4200 + + case platformView = 4300 } } diff --git a/AdaptyUI/Onboardings/Rendering/AdaptyOnboardingViewModel.swift b/AdaptyUI/Onboardings/Rendering/AdaptyOnboardingViewModel.swift index 565dba093..3f21693c5 100644 --- a/AdaptyUI/Onboardings/Rendering/AdaptyOnboardingViewModel.swift +++ b/AdaptyUI/Onboardings/Rendering/AdaptyOnboardingViewModel.swift @@ -20,14 +20,20 @@ private extension AdaptyUI { final class AdaptyOnboardingViewModel: ObservableObject { let logId: String let onboarding: AdaptyOnboarding + let inspectWebView: Bool var onMessage: ((AdaptyOnboardingsMessage) -> Void)? var onError: ((AdaptyUIError) -> Void)? private let webViewDelegate: AdaptyWebViewDelegate - init(logId: String, onboarding: AdaptyOnboarding) { + init( + logId: String, + onboarding: AdaptyOnboarding, + inspectWebView: Bool + ) { self.logId = logId self.onboarding = onboarding + self.inspectWebView = inspectWebView self.webViewDelegate = AdaptyWebViewDelegate(logId: logId) } @@ -49,6 +55,10 @@ final class AdaptyOnboardingViewModel: ObservableObject { webViewDelegate, name: AdaptyUI.webViewEventMessageName ) + + if #available(iOS 16.4, *) { + webView.isInspectable = inspectWebView + } self.webView = webView } diff --git a/AdaptyUI/Rendering/Wrappers/AdaptyPaywallController.swift b/AdaptyUI/Rendering/Wrappers/AdaptyPaywallController.swift index aa7f89971..ae2b62a45 100644 --- a/AdaptyUI/Rendering/Wrappers/AdaptyPaywallController.swift +++ b/AdaptyUI/Rendering/Wrappers/AdaptyPaywallController.swift @@ -12,36 +12,45 @@ import SwiftUI import UIKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +@MainActor public extension AdaptyPaywallController { var paywallPlacementId: String { - paywallConfiguration.paywallViewModel.paywall.placementId + paywallView.configuration.paywallViewModel.paywall.placementId } var paywallVariationId: String { - paywallConfiguration.paywallViewModel.paywall.variationId + paywallView.configuration.paywallViewModel.paywall.variationId } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) public final class AdaptyPaywallController: UIViewController { - public let id = UUID() + public var id: String { paywallView.id } + + var configuration: AdaptyUI.PaywallConfiguration { paywallView.configuration } + + private let logId: String - let paywallConfiguration: AdaptyUI.PaywallConfiguration let showDebugOverlay: Bool + private let paywallView: AdaptyPaywallUIView public weak var delegate: AdaptyPaywallControllerDelegate? - private let logId: String = Log.stamp - init( paywallConfiguration: AdaptyUI.PaywallConfiguration, delegate: AdaptyPaywallControllerDelegate?, showDebugOverlay: Bool ) { - self.paywallConfiguration = paywallConfiguration self.delegate = delegate self.showDebugOverlay = showDebugOverlay + paywallView = AdaptyPaywallUIView( + configuration: paywallConfiguration, + showDebugOverlay: false + ) + + logId = paywallConfiguration.eventsHandler.logId + super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen @@ -65,103 +74,8 @@ public final class AdaptyPaywallController: UIViewController { Log.ui.verbose("#\(logId)# viewDidLoad begin") - view.backgroundColor = .systemBackground - - paywallConfiguration.eventsHandler.didAppear = { [weak self] in - guard let self else { return } - self.delegate?.paywallControllerDidAppear(self) - } - - paywallConfiguration.eventsHandler.didDisappear = { [weak self] in - guard let self else { return } - self.delegate?.paywallControllerDidDisappear(self) - } - - paywallConfiguration.eventsHandler.didPerformAction = { [weak self] action in - guard let self else { return } - self.delegate?.paywallController(self, didPerform: action) - } - - paywallConfiguration.eventsHandler.didSelectProduct = { [weak self] underlying in - guard let self else { return } - self.delegate?.paywallController(self, didSelectProduct: underlying) - } - - paywallConfiguration.eventsHandler.didStartPurchase = { [weak self] underlying in - guard let self else { return } - self.delegate?.paywallController(self, didStartPurchase: underlying) - } - - paywallConfiguration.eventsHandler.didFinishPurchase = { [weak self] underlying, purchaseResult in - guard let self else { return } - self.delegate?.paywallController( - self, - didFinishPurchase: underlying, - purchaseResult: purchaseResult - ) - } - - paywallConfiguration.eventsHandler.didFailPurchase = { [weak self] underlying, error in - guard let self else { return } - self.delegate?.paywallController(self, didFailPurchase: underlying, error: error) - } - - paywallConfiguration.eventsHandler.didStartRestore = { [weak self] in - guard let self else { return } - self.delegate?.paywallControllerDidStartRestore(self) - } - - paywallConfiguration.eventsHandler.didFinishRestore = { [weak self] profile in - guard let self else { return } - self.delegate?.paywallController(self, didFinishRestoreWith: profile) - } - - paywallConfiguration.eventsHandler.didFailRestore = { [weak self] error in - guard let self else { return } - self.delegate?.paywallController(self, didFailRestoreWith: error) - } - - paywallConfiguration.eventsHandler.didFailRendering = { [weak self] error in - guard let self else { return } - self.delegate?.paywallController(self, didFailRenderingWith: error) - } - - paywallConfiguration.eventsHandler.didFailLoadingProducts = { [weak self] error in - guard let self else { return false } - guard let delegate = self.delegate else { return true } - return delegate.paywallController(self, didFailLoadingProductsWith: error) - } - - paywallConfiguration.eventsHandler.didPartiallyLoadProducts = { [weak self] failedIds in - guard let self else { return } - self.delegate?.paywallController(self, didPartiallyLoadProducts: failedIds) - } - - paywallConfiguration.eventsHandler.didFinishWebPaymentNavigation = { [weak self] product, error in - guard let self else { return } - - self.delegate?.paywallController( - self, - didFinishWebPaymentNavigation: product, - error: error - ) - } - - addSubSwiftUIView( - AdaptyPaywallView_Internal( - showDebugOverlay: showDebugOverlay - ) - .environmentObject(paywallConfiguration.eventsHandler) - .environmentObject(paywallConfiguration.paywallViewModel) - .environmentObject(paywallConfiguration.productsViewModel) - .environmentObject(paywallConfiguration.actionsViewModel) - .environmentObject(paywallConfiguration.sectionsViewModel) - .environmentObject(paywallConfiguration.tagResolverViewModel) - .environmentObject(paywallConfiguration.timerViewModel) - .environmentObject(paywallConfiguration.screensViewModel) - .environmentObject(paywallConfiguration.assetsViewModel), - to: view - ) + paywallView.configure(delegate: self) + paywallView.layout(in: view, parentVC: self) Log.ui.verbose("#\(logId)# viewDidLoad end") } @@ -171,7 +85,7 @@ public final class AdaptyPaywallController: UIViewController { Log.ui.verbose("#\(logId)# viewDidAppear") - paywallConfiguration.reportOnAppear() + paywallView.reportOnAppear() } override public func viewDidDisappear(_ animated: Bool) { @@ -179,7 +93,102 @@ public final class AdaptyPaywallController: UIViewController { Log.ui.verbose("#\(logId)# viewDidDisappear") - paywallConfiguration.reportOnDisappear() + paywallView.reportOnDisappear() + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension AdaptyPaywallController: AdaptyPaywallViewDelegate { + package func paywallViewDidAppear(_ view: AdaptyPaywallUIView) { + delegate?.paywallControllerDidAppear(self) + } + + package func paywallViewDidDisappear(_ view: AdaptyPaywallUIView) { + delegate?.paywallControllerDidDisappear(self) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didPerform action: AdaptyUI.Action + ) { + delegate?.paywallController(self, didPerform: action) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didSelectProduct product: AdaptyPaywallProductWithoutDeterminingOffer + ) { + delegate?.paywallController(self, didSelectProduct: product) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didStartPurchase product: AdaptyPaywallProduct + ) { + delegate?.paywallController(self, didStartPurchase: product) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFinishPurchase product: AdaptyPaywallProduct, + purchaseResult: AdaptyPurchaseResult + ) { + delegate?.paywallController(self, didFinishPurchase: product, purchaseResult: purchaseResult) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailPurchase product: AdaptyPaywallProduct, + error: AdaptyError + ) { + delegate?.paywallController(self, didFailPurchase: product, error: error) + } + + package func paywallViewDidStartRestore(_ view: AdaptyPaywallUIView) { + delegate?.paywallControllerDidStartRestore(self) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFinishRestoreWith profile: AdaptyProfile + ) { + delegate?.paywallController(self, didFinishRestoreWith: profile) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailRestoreWith error: AdaptyError + ) { + delegate?.paywallController(self, didFailRestoreWith: error) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailRenderingWith error: AdaptyUIError + ) { + delegate?.paywallController(self, didFailRenderingWith: error) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailLoadingProductsWith error: AdaptyError + ) -> Bool { + delegate?.paywallController(self, didFailLoadingProductsWith: error) ?? true + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didPartiallyLoadProducts failedIds: [String] + ) { + delegate?.paywallController(self, didPartiallyLoadProducts: failedIds) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFinishWebPaymentNavigation product: AdaptyPaywallProduct?, + error: AdaptyError? + ) { + delegate?.paywallController(self, didFinishWebPaymentNavigation: product, error: error) } } diff --git a/AdaptyUI/Rendering/Wrappers/AdaptyPaywallUIView.swift b/AdaptyUI/Rendering/Wrappers/AdaptyPaywallUIView.swift new file mode 100644 index 000000000..c25f73b15 --- /dev/null +++ b/AdaptyUI/Rendering/Wrappers/AdaptyPaywallUIView.swift @@ -0,0 +1,177 @@ +// +// AdaptyPaywallUIView.swift +// Adapty +// +// Created by Alexey Goncharov on 8/6/25. +// + +#if canImport(UIKit) + +import Adapty +import SwiftUI +import UIKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +package final class AdaptyPaywallUIView: UIView { + let id: String + let configuration: AdaptyUI.PaywallConfiguration + let logId: String + + private let showDebugOverlay: Bool + + package init( + configuration: AdaptyUI.PaywallConfiguration, + showDebugOverlay: Bool = false, + id: String = UUID().uuidString + ) { + self.id = id + self.configuration = configuration + self.showDebugOverlay = showDebugOverlay + logId = configuration.eventsHandler.logId + + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + Log.ui.verbose("#\(logId)# view deinit") + } + + weak var delegate: AdaptyPaywallViewDelegate? + + package func configure(delegate: AdaptyPaywallViewDelegate) { + Log.ui.verbose("V #\(logId)# view configure") + + self.delegate = delegate + + configuration.eventsHandler.didAppear = { [weak self] in + guard let self else { return } + self.delegate?.paywallViewDidAppear(self) + } + + configuration.eventsHandler.didDisappear = { [weak self] in + guard let self else { return } + self.delegate?.paywallViewDidDisappear(self) + } + + configuration.eventsHandler.didPerformAction = { [weak self] action in + guard let self else { return } + self.delegate?.paywallView(self, didPerform: action) + } + + configuration.eventsHandler.didSelectProduct = { [weak self] underlying in + guard let self else { return } + self.delegate?.paywallView(self, didSelectProduct: underlying) + } + + configuration.eventsHandler.didStartPurchase = { [weak self] underlying in + guard let self else { return } + self.delegate?.paywallView(self, didStartPurchase: underlying) + } + + configuration.eventsHandler.didFinishPurchase = { [weak self] underlying, purchaseResult in + guard let self else { return } + self.delegate?.paywallView( + self, + didFinishPurchase: underlying, + purchaseResult: purchaseResult + ) + } + + configuration.eventsHandler.didFailPurchase = { [weak self] underlying, error in + guard let self else { return } + self.delegate?.paywallView(self, didFailPurchase: underlying, error: error) + } + + configuration.eventsHandler.didStartRestore = { [weak self] in + guard let self else { return } + self.delegate?.paywallViewDidStartRestore(self) + } + + configuration.eventsHandler.didFinishRestore = { [weak self] profile in + guard let self else { return } + self.delegate?.paywallView(self, didFinishRestoreWith: profile) + } + + configuration.eventsHandler.didFailRestore = { [weak self] error in + guard let self else { return } + self.delegate?.paywallView(self, didFailRestoreWith: error) + } + + configuration.eventsHandler.didFailRendering = { [weak self] error in + guard let self else { return } + self.delegate?.paywallView(self, didFailRenderingWith: error) + } + + configuration.eventsHandler.didFailLoadingProducts = { [weak self] error in + guard let self else { return false } + guard let delegate = self.delegate else { return true } + return delegate.paywallView(self, didFailLoadingProductsWith: error) + } + + configuration.eventsHandler.didPartiallyLoadProducts = { [weak self] failedIds in + guard let self else { return } + self.delegate?.paywallView(self, didPartiallyLoadProducts: failedIds) + } + + configuration.eventsHandler.didFinishWebPaymentNavigation = { [weak self] product, error in + guard let self else { return } + + self.delegate?.paywallView( + self, + didFinishWebPaymentNavigation: product, + error: error + ) + } + } + + package func layout(in parentView: UIView, parentVC: UIViewController) { + Log.ui.verbose("V #\(logId)# view layout(in:parentVC:)") + + backgroundColor = .systemBackground + translatesAutoresizingMaskIntoConstraints = false + + parentView.addSubview(self) + + parentView.addConstraints([ + leadingAnchor.constraint(equalTo: parentView.leadingAnchor), + topAnchor.constraint(equalTo: parentView.topAnchor), + trailingAnchor.constraint(equalTo: parentView.trailingAnchor), + bottomAnchor.constraint(equalTo: parentView.bottomAnchor), + ]) + + parentVC.addSubSwiftUIView( + AdaptyPaywallView_Internal( + showDebugOverlay: showDebugOverlay + ) + .environmentObject(configuration.eventsHandler) + .environmentObject(configuration.paywallViewModel) + .environmentObject(configuration.productsViewModel) + .environmentObject(configuration.actionsViewModel) + .environmentObject(configuration.sectionsViewModel) + .environmentObject(configuration.tagResolverViewModel) + .environmentObject(configuration.timerViewModel) + .environmentObject(configuration.screensViewModel) + .environmentObject(configuration.assetsViewModel), + to: self + ) + } + + package func reportOnAppear() { + Log.ui.verbose("#\(logId)# view reportOnAppear") + + configuration.reportOnAppear() + } + + package func reportOnDisappear() { + Log.ui.verbose("#\(logId)# view reportOnDisappear") + + configuration.reportOnDisappear() + } +} + +#endif diff --git a/AdaptyUI/Rendering/Wrappers/AdaptyPaywallViewDelegate.swift b/AdaptyUI/Rendering/Wrappers/AdaptyPaywallViewDelegate.swift new file mode 100644 index 000000000..16f2d9c03 --- /dev/null +++ b/AdaptyUI/Rendering/Wrappers/AdaptyPaywallViewDelegate.swift @@ -0,0 +1,81 @@ +// +// AdaptyPaywallViewDelegate.swift +// Adapty +// +// Created by Alexey Goncharov on 8/6/25. +// + +#if canImport(UIKit) + +import Adapty +import UIKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +@MainActor +package protocol AdaptyPaywallViewDelegate: AnyObject { + func paywallViewDidAppear(_ view: AdaptyPaywallUIView) + + func paywallViewDidDisappear(_ view: AdaptyPaywallUIView) + + func paywallView( + _ view: AdaptyPaywallUIView, + didPerform action: AdaptyUI.Action + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didSelectProduct product: AdaptyPaywallProductWithoutDeterminingOffer + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didStartPurchase product: AdaptyPaywallProduct + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFinishPurchase product: AdaptyPaywallProduct, + purchaseResult: AdaptyPurchaseResult + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFailPurchase product: AdaptyPaywallProduct, + error: AdaptyError + ) + + func paywallViewDidStartRestore(_ view: AdaptyPaywallUIView) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFinishRestoreWith profile: AdaptyProfile + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFailRestoreWith error: AdaptyError + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFailRenderingWith error: AdaptyUIError + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFailLoadingProductsWith error: AdaptyError + ) -> Bool + + func paywallView( + _ view: AdaptyPaywallUIView, + didPartiallyLoadProducts failedIds: [String] + ) + + func paywallView( + _ view: AdaptyPaywallUIView, + didFinishWebPaymentNavigation product: AdaptyPaywallProduct?, + error: AdaptyError? + ) +} + +#endif diff --git a/AdaptyUITesting/AdaptyUI+Testing.swift b/AdaptyUITesting/AdaptyUI+Testing.swift index 7df188290..7c99a0b94 100644 --- a/AdaptyUITesting/AdaptyUI+Testing.swift +++ b/AdaptyUITesting/AdaptyUI+Testing.swift @@ -180,4 +180,18 @@ public extension View { } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +@MainActor +public extension AdaptyUI { + static func getOnboardingConfigurationForTesting( + forOnboarding onboarding: AdaptyOnboarding, + inspectWebView: Bool + ) throws -> OnboardingConfiguration { + try AdaptyUI.getOnboardingConfiguration( + forOnboarding: onboarding, + inspectWebView: inspectWebView + ) + } +} + #endif diff --git a/Examples/AdaptyRecipes-UIKit/Podfile.lock b/Examples/AdaptyRecipes-UIKit/Podfile.lock new file mode 100644 index 000000000..b5c8159ca --- /dev/null +++ b/Examples/AdaptyRecipes-UIKit/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - Adapty (3.11.0-SNAPSHOT) + - AdaptyUI (3.11.0-SNAPSHOT): + - Adapty (= 3.11.0-SNAPSHOT) + +DEPENDENCIES: + - Adapty (from `../../`) + - AdaptyUI (from `../../`) + +EXTERNAL SOURCES: + Adapty: + :path: "../../" + AdaptyUI: + :path: "../../" + +SPEC CHECKSUMS: + Adapty: 8de4f4878d9cf1c100b04512b273c11092dada16 + AdaptyUI: 33826979a74229f30df4d07b30c3f3fdbbff6a6a + +PODFILE CHECKSUM: 235cfbef6b933d442daf00c8c0f0ceab2561282d + +COCOAPODS: 1.16.2 diff --git a/Examples/OnboardingsDemo-UIKit/Podfile.lock b/Examples/OnboardingsDemo-UIKit/Podfile.lock new file mode 100644 index 000000000..f993a9894 --- /dev/null +++ b/Examples/OnboardingsDemo-UIKit/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - Adapty (3.11.0-SNAPSHOT) + - AdaptyUI (3.11.0-SNAPSHOT): + - Adapty (= 3.11.0-SNAPSHOT) + +DEPENDENCIES: + - Adapty (from `../../`) + - AdaptyUI (from `../../`) + +EXTERNAL SOURCES: + Adapty: + :path: "../../" + AdaptyUI: + :path: "../../" + +SPEC CHECKSUMS: + Adapty: 8de4f4878d9cf1c100b04512b273c11092dada16 + AdaptyUI: 33826979a74229f30df4d07b30c3f3fdbbff6a6a + +PODFILE CHECKSUM: 96066f525b00e06d14d7c1c98f8d36526a8f851a + +COCOAPODS: 1.16.2 diff --git a/README.md b/README.md index fa1325f21..f1339dc71 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,9 @@ Adapty.makePurchase(product: product) { [weak self] result in - View and analyze data by attributes, such as status, channels, campaigns, and more. - Filter, group, and measure metrics by attribution, platform, custom users' segments, and more in a few clicks. -## Examples of Adapty-Demo apps +## Adapty-Demo apps -This is a demo applications for Adapty. Before running the app, you will need to configure the project. - -### 1. [UIKit](https://github.com/adaptyteam/AdaptySDK-iOS/tree/master/Examples/UIKit-Demo) -### 2. [SwiftUI](https://github.com/adaptyteam/AdaptySDK-iOS/tree/master/Examples/SwiftUI-Demo) - -![Adapty: An example of the paywall is changed on the fly](https://adapty-portal-media-production.s3.amazonaws.com/github/swift-ui-example.jpg) +[Here](Examples/) you can find a demo application for Adapty. Before running the app, you will need to configure the project. ## Mobile App Monetization's Largest Community diff --git a/Sources.AdaptyPlugin/Codable/AdaptyPluginPaywallProduct+Codable.swift b/Sources.AdaptyPlugin/Codable/AdaptyPluginPaywallProduct+Codable.swift index c8bd41666..13bfdb30d 100644 --- a/Sources.AdaptyPlugin/Codable/AdaptyPluginPaywallProduct+Codable.swift +++ b/Sources.AdaptyPlugin/Codable/AdaptyPluginPaywallProduct+Codable.swift @@ -10,8 +10,8 @@ import Foundation extension Request { struct AdaptyPluginPaywallProduct: Decodable { - let vendorProductId: String let adaptyProductId: String + let productInfo: BackendProductInfo let paywallProductIndex: Int let subscriptionOfferIdentifier: AdaptySubscriptionOffer.Identifier? let variationId: String @@ -21,8 +21,12 @@ extension Request { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - vendorProductId = try container.decode(String.self, forKey: .vendorProductId) adaptyProductId = try container.decode(String.self, forKey: .adaptyProductId) + productInfo = try BackendProductInfo( + vendorId: container.decode(String.self, forKey: .vendorProductId), + accessLevelId: container.decode(String.self, forKey: .accessLevelId), + period: BackendProductInfo.Period(rawValue: container.decode(String.self, forKey: .adaptyProductType)) + ) paywallProductIndex = try container.decode(Int.self, forKey: .paywallProductIndex) subscriptionOfferIdentifier = try container.decodeIfPresent(AdaptySubscriptionOffer.Identifier.self, forKey: .subscriptionOfferIdentifier) variationId = try container.decode(String.self, forKey: .paywallVariationId) @@ -36,6 +40,8 @@ extension Request { private enum CodingKeys: String, CodingKey { case vendorProductId = "vendor_product_id" case adaptyProductId = "adapty_product_id" + case accessLevelId = "access_level_id" + case adaptyProductType = "product_type" case paywallProductIndex = "paywall_product_index" case paywallVariationId = "paywall_variation_id" case paywallABTestName = "paywall_ab_test_name" @@ -62,6 +68,8 @@ extension Response { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(wrapped.vendorProductId, forKey: .vendorProductId) try container.encode(wrapped.adaptyProductId, forKey: .adaptyProductId) + try container.encode(wrapped.accessLevelId, forKey: .accessLevelId) + try container.encode(wrapped.adaptyProductType, forKey: .adaptyProductType) try container.encode(wrapped.paywallProductIndex, forKey: .paywallProductIndex) try container.encode(wrapped.variationId, forKey: .paywallVariationId) try container.encode(wrapped.paywallABTestName, forKey: .paywallABTestName) diff --git a/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.Identifier+Codable.swift b/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.Identifier+Codable.swift index 63a47ae92..4e41407d2 100644 --- a/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.Identifier+Codable.swift +++ b/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.Identifier+Codable.swift @@ -14,11 +14,9 @@ extension AdaptySubscriptionOffer.Identifier: Codable { case offerId = "id" } - private typealias OfferType = AdaptySubscriptionOffer.OfferType.CodingValues - package init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let offerType = try container.decode(OfferType.self, forKey: .offerType) + let offerType = try container.decode(AdaptySubscriptionOfferType.self, forKey: .offerType) switch offerType { case .introductory: self = .introductory @@ -26,20 +24,14 @@ extension AdaptySubscriptionOffer.Identifier: Codable { self = try .promotional(container.decode(String.self, forKey: .offerId)) case .winBack: self = try .winBack(container.decode(String.self, forKey: .offerId)) + case .code: + self = try .code(container.decodeIfPresent(String.self, forKey: .offerId)) } } package func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .introductory: - try container.encode(OfferType.introductory, forKey: .offerType) - case .promotional(let id): - try container.encode(OfferType.promotional, forKey: .offerType) - try container.encode(id, forKey: .offerId) - case .winBack(let id): - try container.encode(OfferType.winBack, forKey: .offerType) - try container.encode(id, forKey: .offerId) - } + try container.encode(offerType, forKey: .offerType) + try container.encodeIfPresent(offerId, forKey: .offerId) } } diff --git a/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.OfferType+Codable.swift b/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.OfferType+Codable.swift deleted file mode 100644 index d48ac5a1c..000000000 --- a/Sources.AdaptyPlugin/Codable/AdaptySubscriptionOffer.OfferType+Codable.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// AdaptySubscriptionOffer.OfferType.swift -// AdaptyPlugin -// -// Created by Aleksei Valiano on 12.11.2024. -// - -import Adapty -import Foundation - -extension AdaptySubscriptionOffer.OfferType: Codable { - - public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - self = - switch try container.decode(CodingValues.self) { - case .introductory: - .introductory - case .promotional: - .promotional - case .winBack: - .winBack - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(encodedValue) - } -} diff --git a/Sources.AdaptyPlugin/Errors/AdaptyPluginError.swift b/Sources.AdaptyPlugin/Errors/AdaptyPluginError.swift index 2606c8523..3fe0a17f8 100644 --- a/Sources.AdaptyPlugin/Errors/AdaptyPluginError.swift +++ b/Sources.AdaptyPlugin/Errors/AdaptyPluginError.swift @@ -6,6 +6,7 @@ // import Adapty +import AdaptyUI public struct AdaptyPluginError: Error, Encodable { let errorCode: Int @@ -24,35 +25,35 @@ extension AdaptyPluginError { if let adaptyError = error as? AdaptyError { return adaptyError.asAdaptyPluginError } - + return AdaptyPluginError( errorCode: AdaptyError.ErrorCode.unknown.rawValue, message: "Unknown: \(error.localizedDescription)", detail: "AdaptyPluginError.unknown: \(String(describing: error))" ) } - + static func encodingFailed(message: String? = nil, _ error: Error) -> AdaptyPluginError { let message = message ?? "Encoding failed" - + let detail = (error as? Encodable) .flatMap { try? AdaptyPlugin.encoder.encode($0).asAdaptyJsonString } - ?? "\(message) \(String(describing: error))" - + ?? "\(message) \(String(describing: error))" + return .init( errorCode: AdaptyError.ErrorCode.encodingFailed.rawValue, message: "\(message): \(error.localizedDescription)", detail: "AdaptyPluginError.encodingFailed: \(detail)" ) } - + static func decodingFailed(message: String? = nil, _ error: Error) -> AdaptyPluginError { let message = message ?? "Decoding failed" - + let detail = (error as? Encodable) .flatMap { try? AdaptyPlugin.encoder.encode($0).asAdaptyJsonString } - ?? "\(message) \(String(describing: error))" - + ?? "\(message) \(String(describing: error))" + return .init( errorCode: AdaptyError.ErrorCode.decodingFailed.rawValue, message: "\(message): \(error.localizedDescription)", @@ -60,3 +61,13 @@ extension AdaptyPluginError { ) } } + +public extension AdaptyPluginError { + static func platformViewError(_ message: String) -> AdaptyPluginError { + return .init( + errorCode: AdaptyUIError.Code.platformView.rawValue, + message: message, + detail: "AdaptyPluginError.platformView initialization Failed" + ) + } +} diff --git a/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPaywallPlatformViewWrapper.swift b/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPaywallPlatformViewWrapper.swift new file mode 100644 index 000000000..b1265f1a4 --- /dev/null +++ b/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPaywallPlatformViewWrapper.swift @@ -0,0 +1,219 @@ +// +// AdaptyPaywallPlatformViewWrapper.swift +// Adapty +// +// Created by Alexey Goncharov on 8/6/25. +// + +#if canImport(UIKit) + + import Adapty + import AdaptyUI + import UIKit + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + public final class AdaptyPaywallPlatformViewWrapper: UIView { + private let eventHandler: EventHandler + private let paywallView: AdaptyPaywallUIView + private let parentVC: UIViewController + + public init( + viewId: String, + eventHandler: EventHandler, + configuration: AdaptyUI.PaywallConfiguration, + parentVC: UIViewController + ) { + self.eventHandler = eventHandler + self.parentVC = parentVC + + paywallView = AdaptyPaywallUIView( + configuration: configuration, + id: viewId + ) + + super.init(frame: .zero) + + layout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func layout() { + paywallView.configure(delegate: self) + paywallView.layout(in: self, parentVC: parentVC) + paywallView.reportOnAppear() + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + @MainActor + extension AdaptyPaywallPlatformViewWrapper: AdaptyPaywallViewDelegate { + package func paywallViewDidAppear(_ view: AdaptyPaywallUIView) { + eventHandler.handle( + event: PaywallViewEvent.DidAppear( + view: view.toAdaptyUIView() + ) + ) + } + + package func paywallViewDidDisappear(_ view: AdaptyPaywallUIView) { + eventHandler.handle( + event: PaywallViewEvent.DidDisappear( + view: view.toAdaptyUIView() + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didPerform action: AdaptyUI.Action + ) { + eventHandler.handle( + event: PaywallViewEvent.DidUserAction( + view: view.toAdaptyUIView(), + action: action + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didSelectProduct product: AdaptyPaywallProductWithoutDeterminingOffer + ) { + eventHandler.handle( + event: PaywallViewEvent.DidSelectProduct( + view: view.toAdaptyUIView(), + productVendorId: product.vendorProductId + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didStartPurchase product: AdaptyPaywallProduct + ) { + eventHandler.handle( + event: PaywallViewEvent.WillPurchase( + view: view.toAdaptyUIView(), + product: Response.AdaptyPluginPaywallProduct(product) + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFinishPurchase product: AdaptyPaywallProduct, + purchaseResult: AdaptyPurchaseResult + ) { + + eventHandler.handle( + event: PaywallViewEvent.DidPurchase( + view: view.toAdaptyUIView(), + product: Response.AdaptyPluginPaywallProduct(product), + purchasedResult: purchaseResult + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailPurchase product: AdaptyPaywallProduct, + error: AdaptyError + ) { + eventHandler.handle( + event: PaywallViewEvent.DidFailPurchase( + view: view.toAdaptyUIView(), + product: Response.AdaptyPluginPaywallProduct(product), + error: error + ) + ) + } + + package func paywallViewDidStartRestore(_ view: AdaptyPaywallUIView) { + eventHandler.handle( + event: PaywallViewEvent.WillRestorePurchases( + view: view.toAdaptyUIView() + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFinishRestoreWith profile: AdaptyProfile + ) { + eventHandler.handle( + event: PaywallViewEvent.DidRestorePurchases( + view: view.toAdaptyUIView(), + profile: profile + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailRestoreWith error: AdaptyError + ) { + eventHandler.handle( + event: PaywallViewEvent.DidFailRestorePurchases( + view: view.toAdaptyUIView(), + error: error + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailRenderingWith error: AdaptyUIError + ) { + eventHandler.handle( + event: PaywallViewEvent.DidFailRendering( + view: view.toAdaptyUIView(), + error: error + ) + ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFailLoadingProductsWith error: AdaptyError + ) -> Bool { + eventHandler.handle( + event: PaywallViewEvent.DidFailLoadingProducts( + view: view.toAdaptyUIView(), + error: error + ) + ) + return false + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didPartiallyLoadProducts failedIds: [String] + ) { +// eventHandler.handle( +// event: PaywallViewEvent.DidPartiallyLoadProducts( +// view: view.toAdaptyUIView(), +// failedIds: failedIds +// ) +// ) + } + + package func paywallView( + _ view: AdaptyPaywallUIView, + didFinishWebPaymentNavigation product: AdaptyPaywallProduct?, + error: AdaptyError? + ) { + eventHandler.handle( + event: PaywallViewEvent.DidFinishWebPaymentNavigation( + view: view.toAdaptyUIView(), + product: product.map(Response.AdaptyPluginPaywallProduct.init), + error: error + ) + ) + } + } + +#endif diff --git a/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPlugin+NativeViewRequest.swift b/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPlugin+NativeViewRequest.swift index e3438eba7..cfb597dfe 100644 --- a/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPlugin+NativeViewRequest.swift +++ b/Sources.AdaptyPlugin/PlatformViewSupport/AdaptyPlugin+NativeViewRequest.swift @@ -6,12 +6,32 @@ // import Adapty +import AdaptyUI import Foundation private let log = Log.plugin // TODO: refactor this + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) public extension AdaptyPlugin { + static func getPaywallViewConfiguration( + withJson jsonString: AdaptyJsonString + ) async throws -> AdaptyUI.PaywallConfiguration { + let request = try AdaptyPlugin.decoder.decode( + Request.AdaptyUICreatePaywallView.self, + from: jsonString.asAdaptyJsonData + ) + + return try await AdaptyUI.getPaywallConfiguration( + forPaywall: request.paywall, + loadTimeout: request.loadTimeout, + tagResolver: request.customTags, + timerResolver: request.customTimers, + assetsResolver: request.customAssets?.assetsResolver() + ) + } + static func executeCreateNativeOnboardingView(withJson jsonString: AdaptyJsonString) async -> AdaptyOnboarding? { do { return try AdaptyPlugin.decoder.decode( diff --git a/Sources.AdaptyPlugin/Requests/Request.AdaptyUICreatePaywallView.swift b/Sources.AdaptyPlugin/Requests/Request.AdaptyUICreatePaywallView.swift index ca75c9b58..ba3fa9b84 100644 --- a/Sources.AdaptyPlugin/Requests/Request.AdaptyUICreatePaywallView.swift +++ b/Sources.AdaptyPlugin/Requests/Request.AdaptyUICreatePaywallView.swift @@ -11,6 +11,41 @@ import Adapty import AdaptyUI import Foundation +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +public extension [AdaptyCustomAsset.Identifiable] { + @MainActor + func assetsResolver() throws -> [String: AdaptyCustomAsset]? { + guard !isEmpty else { return nil } + + var assetsResolver = [String: AdaptyCustomAsset]() + assetsResolver.reserveCapacity(count) + + for asset in self { + switch asset.value { + case .asset(let value): + assetsResolver[asset.id] = value + case .imageFlutterAssetId(let assetId): + assetsResolver[asset.id] = try .image(.file(url: url(assetId))) + case .videoFlutterAssetId(let assetId): + assetsResolver[asset.id] = try .video(.file(url: url(assetId), preview: nil)) + } + } + + return assetsResolver + + func url(_ assetId: String) throws -> URL { + guard let assetIdToFileURL = AdaptyPlugin.assetIdToFileURL else { + throw AdaptyPluginInternalError.unregister("Unregister assetIdToFileURL in AdaptyPlugin") + } + guard let url = assetIdToFileURL(assetId) else { + throw AdaptyPluginInternalError.notExist("Asset \(assetId) not found") + } + + return url + } + } +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) extension Request { struct AdaptyUICreatePaywallView: AdaptyPluginRequest { @@ -38,58 +73,28 @@ extension Request { @MainActor func executeInMainActor() async throws -> AdaptyJsonData { - try .success(await AdaptyUI.Plugin.createPaywallView( - paywall: paywall, - loadTimeout: loadTimeout, - preloadProducts: preloadProducts ?? false, - tagResolver: customTags, - timerResolver: customTimers, - assetsResolver: assetsResolver() - )) + try .success( + await AdaptyUI.Plugin.createPaywallView( + paywall: paywall, + loadTimeout: loadTimeout, + preloadProducts: preloadProducts ?? false, + tagResolver: customTags, + timerResolver: customTimers, + assetsResolver: customAssets?.assetsResolver() + ) + ) } - - @MainActor - func assetsResolver() throws -> [String: AdaptyCustomAsset]? { - guard let customAssets, !customAssets.isEmpty else { return nil } - - var assetsResolver = [String: AdaptyCustomAsset]() - assetsResolver.reserveCapacity(customAssets.count) - - for asset in customAssets { - switch asset.value { - case .asset(let value): - assetsResolver[asset.id] = value - case .imageFlutterAssetId(let assetId): - assetsResolver[asset.id] = try .image(.file(url: url(assetId))) - case .videoFlutterAssetId(let assetId): - assetsResolver[asset.id] = try .video(.file(url: url(assetId), preview: nil)) - } - } - - return assetsResolver - - func url(_ assetId: String) throws -> URL { - guard let assetIdToFileURL = Self.assetIdToFileURL else { - throw AdaptyPluginInternalError.unregister("Unregister assetIdToFileURL in AdaptyPlugin") - } - guard let url = assetIdToFileURL(assetId) else { - throw AdaptyPluginInternalError.notExist("Asset \(assetId) not found") - } - - return url - } - } - - @MainActor - fileprivate static var assetIdToFileURL: (@MainActor (String) -> URL?)? } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) public extension AdaptyPlugin { + @MainActor + fileprivate static var assetIdToFileURL: (@MainActor (String) -> URL?)? + @MainActor static func register(createPaywallView: @MainActor @escaping (String) -> URL?) { - Request.AdaptyUICreatePaywallView.assetIdToFileURL = createPaywallView + assetIdToFileURL = createPaywallView } } diff --git a/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentOnboardingView.swift b/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentOnboardingView.swift index 2fce41cb2..fa3036510 100644 --- a/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentOnboardingView.swift +++ b/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentOnboardingView.swift @@ -14,14 +14,17 @@ extension Request { static let method = "adapty_ui_present_onboarding_view" let viewId: String + let presentationStyle: AdaptyUIViewPresentationStyle? enum CodingKeys: String, CodingKey { case viewId = "id" + case presentationStyle = "ios_presentation_style" } func execute() async throws -> AdaptyJsonData { try await AdaptyUI.Plugin.presentOnboardingView( - viewId: viewId + viewId: viewId, + presentationStyle: presentationStyle ) return .success() } diff --git a/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentPaywallView.swift b/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentPaywallView.swift index 62350c972..02a7938af 100644 --- a/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentPaywallView.swift +++ b/Sources.AdaptyPlugin/Requests/Request.AdaptyUIPresentPaywallView.swift @@ -14,14 +14,17 @@ extension Request { static let method = "adapty_ui_present_paywall_view" let viewId: String + let presentationStyle: AdaptyUIViewPresentationStyle? enum CodingKeys: String, CodingKey { case viewId = "id" + case presentationStyle = "ios_presentation_style" } func execute() async throws -> AdaptyJsonData { try await AdaptyUI.Plugin.presentPaywallView( - viewId: viewId + viewId: viewId, + presentationStyle: presentationStyle ) return .success() } diff --git a/Sources.AdaptyPlugin/Requests/Request.CreateWebPaywallUrl.swift b/Sources.AdaptyPlugin/Requests/Request.CreateWebPaywallUrl.swift index 7df8ccf78..9f75f2822 100644 --- a/Sources.AdaptyPlugin/Requests/Request.CreateWebPaywallUrl.swift +++ b/Sources.AdaptyPlugin/Requests/Request.CreateWebPaywallUrl.swift @@ -32,8 +32,8 @@ extension Request { switch self { case .product(let product): let product = try await Adapty.getPaywallProduct( - vendorProductId: product.vendorProductId, adaptyProductId: product.adaptyProductId, + productInfo: product.productInfo, paywallProductIndex: product.paywallProductIndex, subscriptionOfferIdentifier: product.subscriptionOfferIdentifier, variationId: product.variationId, diff --git a/Sources.AdaptyPlugin/Requests/Request.Identify.swift b/Sources.AdaptyPlugin/Requests/Request.Identify.swift index b79c3854c..d11326809 100644 --- a/Sources.AdaptyPlugin/Requests/Request.Identify.swift +++ b/Sources.AdaptyPlugin/Requests/Request.Identify.swift @@ -13,15 +13,15 @@ extension Request { static let method = "identify" let customerUserId: String - let appAccountToken: UUID? + let parameters: CustomerIdentityParameters? enum CodingKeys: String, CodingKey { case customerUserId = "customer_user_id" - case appAccountToken = "app_account_token" + case parameters } func execute() async throws -> AdaptyJsonData { - try await Adapty.identify(customerUserId, withAppAccountToken: appAccountToken) + try await Adapty.identify(customerUserId, withAppAccountToken: parameters?.appAccountToken) return .success() } } diff --git a/Sources.AdaptyPlugin/Requests/Request.MakePurchase.swift b/Sources.AdaptyPlugin/Requests/Request.MakePurchase.swift index 730c96c81..221c229f9 100644 --- a/Sources.AdaptyPlugin/Requests/Request.MakePurchase.swift +++ b/Sources.AdaptyPlugin/Requests/Request.MakePurchase.swift @@ -19,8 +19,8 @@ extension Request { func execute() async throws -> AdaptyJsonData { let product = try await Adapty.getPaywallProduct( - vendorProductId: product.vendorProductId, adaptyProductId: product.adaptyProductId, + productInfo: product.productInfo, paywallProductIndex: product.paywallProductIndex, subscriptionOfferIdentifier: product.subscriptionOfferIdentifier, variationId: product.variationId, diff --git a/Sources.AdaptyPlugin/Requests/Request.OpenWebPaywall.swift b/Sources.AdaptyPlugin/Requests/Request.OpenWebPaywall.swift index e12f2c423..5ae3f5fbc 100644 --- a/Sources.AdaptyPlugin/Requests/Request.OpenWebPaywall.swift +++ b/Sources.AdaptyPlugin/Requests/Request.OpenWebPaywall.swift @@ -32,8 +32,8 @@ extension Request { switch self { case .product(let product): let product = try await Adapty.getPaywallProduct( - vendorProductId: product.vendorProductId, adaptyProductId: product.adaptyProductId, + productInfo: product.productInfo, paywallProductIndex: product.paywallProductIndex, subscriptionOfferIdentifier: product.subscriptionOfferIdentifier, variationId: product.variationId, diff --git a/Sources.AdaptyPlugin/Requests/Request.ReportTransaction.swift b/Sources.AdaptyPlugin/Requests/Request.ReportTransaction.swift index c1dcc065c..257998032 100644 --- a/Sources.AdaptyPlugin/Requests/Request.ReportTransaction.swift +++ b/Sources.AdaptyPlugin/Requests/Request.ReportTransaction.swift @@ -21,8 +21,8 @@ extension Request { } func execute() async throws -> AdaptyJsonData { - let profile = try await Adapty.reportTransaction(transactionId, withVariationId: variationId) - return .success(profile) + try await Adapty.reportTransaction(transactionId, withVariationId: variationId) + return .success() } } } diff --git a/Sources.AdaptyPlugin/Requests/Request.UpdateProfile.swift b/Sources.AdaptyPlugin/Requests/Request.UpdateProfile.swift index 4dd351479..c7e6cc8a6 100644 --- a/Sources.AdaptyPlugin/Requests/Request.UpdateProfile.swift +++ b/Sources.AdaptyPlugin/Requests/Request.UpdateProfile.swift @@ -18,6 +18,12 @@ extension Request { case params } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let params = try container.decode(AdaptyProfileParameters.Builder.self, forKey: .params) + self.params = params.build() + } + func execute() async throws -> AdaptyJsonData { try await Adapty.updateProfile(params: params) return .success() diff --git a/Sources.AdaptyPlugin/cross_platform.yaml b/Sources.AdaptyPlugin/cross_platform.yaml index 26f4809b5..2d8a198ac 100644 --- a/Sources.AdaptyPlugin/cross_platform.yaml +++ b/Sources.AdaptyPlugin/cross_platform.yaml @@ -1,5 +1,5 @@ $schema: "https://json-schema.org/draft/2020-12/schema" -$id: "https://adapty.io/crossPlatform/3.11.0/schema" +$id: "https://adapty.io/crossPlatform/3.12.0/schema" title: "Cross Platform Format" $requests: @@ -112,6 +112,7 @@ $requests: properties: method: { const: "adapty_ui_present_onboarding_view" } id: { type: string, description: "View Id" } + ios_presentation_style: { $ref: "#/$defs/AdaptyUI.IOSPresentationStyle" } AdaptyUIPresentOnboardingView.Response: #response type: object @@ -130,6 +131,7 @@ $requests: properties: method: { const: "adapty_ui_present_paywall_view" } id: { type: string, description: "View Id" } + ios_presentation_style: { $ref: "#/$defs/AdaptyUI.IOSPresentationStyle" } AdaptyUIPresentPaywallView.Response: #response type: object @@ -288,7 +290,7 @@ $requests: properties: method: { const: "identify" } customer_user_id: { type: string } - app_account_token: { $ref: "#/$defs/UUID" } + parameters: { $ref: "#/$defs/CustomerIdentityParameters" } Identify.Response: #response type: object @@ -505,7 +507,7 @@ $requests: error: { $ref: "#/$defs/AdaptyError" } - required: [success] properties: - success: { $ref: "#/$defs/AdaptyProfile" } + success: { const: true } ### restorePurchases ### RestorePurchases.Request: #request @@ -924,7 +926,7 @@ $defs: type: string format: uuid pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" - description: "UUID в формате RFC 4122" + description: "UUID in RFC 4122 format" example: "123e4567-e89b-12d3-a456-426614174000" Date: #response #request @@ -937,10 +939,11 @@ $defs: properties: api_key: { type: string } customer_user_id: { type: string } - app_account_token: { $ref: "#/$defs/UUID" , description: "used only if customer_user_id is not null"} + customer_identity_parameters: { $ref: "#/$defs/CustomerIdentityParameters" } observer_mode: { type: boolean, default: false } apple_idfa_collection_disabled: { type: boolean, default: false } google_adid_collection_disabled: { type: boolean, default: false } + google_enable_pending_prepaid_plans: { type: boolean, default: false } ip_address_collection_disabled: { type: boolean, default: false } server_cluster: { type: string, enum: ["default", "eu", "cn"] } backend_base_url: { type: string } @@ -961,6 +964,13 @@ $defs: memory_storage_count_limit: { type: integer } disk_storage_size_limit: { type: integer, description: "bytes" } + CustomerIdentityParameters: #request + type: object + properties: + app_account_token: { $ref: "#/$defs/UUID", description: "iOS Only" } + obfuscated_account_id: { type: string, description: "Android Only" } + obfuscated_profile_id: { type: string, description: "Android Only" } + AdaptyPaywallProduct.Response: #response type: object required: @@ -1026,6 +1036,8 @@ $defs: required: - vendor_product_id - adapty_product_id + - access_level_id + - product_type - paywall_product_index - paywall_variation_id - paywall_ab_test_name @@ -1033,6 +1045,8 @@ $defs: properties: vendor_product_id: { type: string } adapty_product_id: { type: string } + access_level_id: { type: string } + product_type: { type: string } paywall_product_index: { type: integer } subscription_offer_identifier: { $ref: "#/$defs/AdaptySubscriptionOffer.Identifier" } @@ -1179,9 +1193,13 @@ $defs: required: - vendor_product_id - adapty_product_id + - access_level_id + - product_type properties: vendor_product_id: { type: string } adapty_product_id: { type: string } + access_level_id: { type: string } + product_type: { type: string } promotional_offer_id: { type: string, description: "iOS Only" } win_back_offer_id: { type: string, description: "iOS Only" } base_plan_id: { type: string, description: "Android Only" } @@ -1396,7 +1414,9 @@ $defs: properties: type: { type: string, const: "success" } profile: { $ref: "#/$defs/AdaptyProfile" } - jws_transaction: { type: string } + apple_jws_transaction: { type: string } + google_purchase_token: { type: string } + AdaptyInstallationStatus: #response #event type: object @@ -1480,6 +1500,12 @@ $defs: nullable: false } + AdaptyUI.IOSPresentationStyle: + type: string + enum: + - full_screen + - page_sheet + AdaptyUI.DialogConfiguration: #request type: object required: [default_action] @@ -1504,8 +1530,6 @@ $defs: description: "Android Only", } is_offer_personalized: { type: boolean, description: "Android Only" } - obfuscated_account_id: { type: string, description: "Android Only" } - obfuscated_profile_id: { type: string, description: "Android Only" } AdaptySubscriptionUpdateParameters: #request # Android Only diff --git a/Sources/Adapty+Activate.swift b/Sources/Adapty+Activate.swift index 05067a3c5..0c9a7ca89 100644 --- a/Sources/Adapty+Activate.swift +++ b/Sources/Adapty+Activate.swift @@ -63,6 +63,7 @@ public extension Adapty { await Storage.clearAllDataIfDifferent(apiKey: configuration.apiKey) + AdaptyConfiguration.transactionFinishBehavior = configuration.transactionFinishBehavior AdaptyConfiguration.callbackDispatchQueue = configuration.callbackDispatchQueue // TODO: Refactoring AdaptyConfiguration.idfaCollectionDisabled = configuration.idfaCollectionDisabled // TODO: Refactoring AdaptyConfiguration.ipAddressCollectionDisabled = configuration.ipAddressCollectionDisabled // TODO: Refactoring diff --git a/Sources/Adapty+Completion.swift b/Sources/Adapty+Completion.swift index 7ba4ece40..6ad0fc477 100644 --- a/Sources/Adapty+Completion.swift +++ b/Sources/Adapty+Completion.swift @@ -134,7 +134,7 @@ public extension Adapty { let data = try JSONSerialization.data(withJSONObject: attribution) attributionJson = String(decoding: data, as: UTF8.self) } catch { - completion?(AdaptyError.wrongAttributeData(error)) + completion?(.wrongAttributeData(error)) return } @@ -352,6 +352,15 @@ public extension Adapty { } } + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + static func getUnfinishedTransactions( + _ completion: @escaping AdaptyResultCompletion<[AdaptyUnfinishedTransaction]> + ) { + withCompletion(completion) { () async throws(AdaptyError) in + try await getUnfinishedTransactions() + } + } + /// You can fetch the StoreKit receipt by calling this method /// /// If the receipt is not presented on the device, Adapty will try to refresh it by using [SKReceiptRefreshRequest](https://developer.apple.com/documentation/storekit/skreceiptrefreshrequest) @@ -390,7 +399,7 @@ public extension Adapty { @available(*, deprecated, renamed: "reportTransaction") nonisolated static func setVariationId( _ variationId: String, - forPurchasedTransaction sk1Transaction: SKPaymentTransaction + forPurchasedTransaction sk1Transaction: StoreKit.SKPaymentTransaction ) async throws(AdaptyError) { try await reportTransaction(sk1Transaction, withVariationId: variationId) } @@ -423,7 +432,7 @@ public extension Adapty { @available(*, deprecated, renamed: "reportTransaction") nonisolated static func setVariationId( _ variationId: String, - forPurchasedTransaction transaction: SKPaymentTransaction, + forPurchasedTransaction transaction: StoreKit.SKPaymentTransaction, _ completion: AdaptyErrorCompletion? = nil ) { withCompletion(completion) { () async throws(AdaptyError) in @@ -443,7 +452,7 @@ public extension Adapty { @available(*, deprecated, renamed: "reportTransaction") nonisolated static func setVariationId( _ variationId: String, - forPurchasedTransaction transaction: Transaction, + forPurchasedTransaction transaction: StoreKit.Transaction, _ completion: AdaptyErrorCompletion? = nil ) { withCompletion(completion) { () async throws(AdaptyError) in @@ -460,7 +469,7 @@ public extension Adapty { /// - transaction: A purchased transaction (note, that this method is suitable only for Store Kit version 1) [SKPaymentTransaction](https://developer.apple.com/documentation/storekit/skpaymenttransaction). /// - completion: A result containing an optional error. nonisolated static func reportTransaction( - _ transaction: SKPaymentTransaction, + _ transaction: StoreKit.SKPaymentTransaction, withVariationId variationId: String? = nil, _ completion: AdaptyErrorCompletion? = nil ) { @@ -479,7 +488,26 @@ public extension Adapty { /// - completion: A result containing an optional error. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) nonisolated static func reportTransaction( - _ transaction: Transaction, + _ transaction: StoreKit.Transaction, + withVariationId variationId: String? = nil, + _ completion: AdaptyErrorCompletion? = nil + ) { + withCompletion(completion) { () async throws(AdaptyError) in + try await reportTransaction(transaction, withVariationId: variationId) + } + } + + /// Link purchased transaction with paywall's variationId. + /// + /// In [Observer mode](https://docs.adapty.io/docs/ios-observer-mode), Adapty SDK doesn't know, where the purchase was made from. If you display products using our [Paywalls](https://docs.adapty.io/docs/paywall) or [A/B Tests](https://docs.adapty.io/docs/ab-test), you can manually assign variation to the purchase. After doing this, you'll be able to see metrics in Adapty Dashboard. + /// + /// - Parameters: + /// - variationId: A string identifier of variation. You can get it using variationId property of `AdaptyPaywall`. + /// - transaction: A purchased verification result of transaction (note, that this method is suitable only for Store Kit version 2) [VerificationResult](https://developer.apple.com/documentation/storekit/verificationresult). + /// - completion: A result containing an optional error. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + nonisolated static func reportTransaction( + _ transaction: StoreKit.VerificationResult, withVariationId variationId: String? = nil, _ completion: AdaptyErrorCompletion? = nil ) { @@ -488,6 +516,25 @@ public extension Adapty { } } + /// Link product purchase result with paywall's variationId. + /// + /// In [Observer mode](https://docs.adapty.io/docs/ios-observer-mode), Adapty SDK doesn't know, where the purchase was made from. If you display products using our [Paywalls](https://docs.adapty.io/docs/paywall) or [A/B Tests](https://docs.adapty.io/docs/ab-test), you can manually assign variation to the purchase. After doing this, you'll be able to see metrics in Adapty Dashboard. + /// + /// - Parameters: + /// - variationId: A string identifier of variation. You can get it using variationId property of `AdaptyPaywall`. + /// - purchaseResult: A product purchase result (note, that this method is suitable only for Store Kit version 2) [Product.PurchaseResult](https://developer.apple.com/documentation/storekit/product/purchaseresult). + /// - completion: A result containing an optional error. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + nonisolated static func reportPurchaseResult( + _ purchaseResult: StoreKit.Product.PurchaseResult, + withVariationId variationId: String? = nil, + _ completion: AdaptyErrorCompletion? = nil + ) { + withCompletion(completion) { () async throws(AdaptyError) in + try await reportPurchaseResult(purchaseResult, withVariationId: variationId) + } + } + /// Call this method to notify Adapty SDK, that particular paywall was shown to user. /// /// Adapty helps you to measure the performance of the paywalls. We automatically collect all the metrics related to purchases except for paywall views. This is because only you know when the paywall was shown to a customer. @@ -571,6 +618,17 @@ public extension Adapty { } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +public extension AdaptyUnfinishedTransaction { + nonisolated func finish( + _ completion: AdaptyErrorCompletion? = nil + ) { + withCompletion(completion) { () async throws(AdaptyError) in + try await finish() + } + } +} + private func withCompletion( _ completion: AdaptyErrorCompletion? = nil, from operation: @escaping @Sendable () async throws(AdaptyError) -> Void diff --git a/Sources/Adapty+Shared.swift b/Sources/Adapty+Shared.swift index ee08b8d47..838d7d40d 100644 --- a/Sources/Adapty+Shared.swift +++ b/Sources/Adapty+Shared.swift @@ -9,6 +9,7 @@ import Foundation private let log = Log.default +@AdaptyActor extension Adapty { public static var isActivated: Bool { shared != nil } @@ -28,18 +29,18 @@ extension Adapty { package static var activatedSDK: Adapty { get async throws(AdaptyError) { switch shared { - case let .some(.activated(sdk)): + case let .activated(sdk)?: return sdk - case let .some(.activating(task)): + case let .activating(task)?: return await task.value default: - throw AdaptyError.notActivated() + throw .notActivated() } } } static var optionalSDK: Adapty? { // TODO: Deprecated - if case let .some(.activated(sdk)) = shared { + if case let .activated(sdk)? = shared { sdk } else { nil diff --git a/Sources/Adapty.swift b/Sources/Adapty.swift index e5dc59086..c3dd269ad 100644 --- a/Sources/Adapty.swift +++ b/Sources/Adapty.swift @@ -7,8 +7,10 @@ import Foundation +private let log = Log.default + @AdaptyActor -public final class Adapty: Sendable { +public final class Adapty { let profileStorage: ProfileStorage let apiKeyPrefix: String @@ -21,12 +23,12 @@ public final class Adapty: Sendable { let receiptManager: StoreKitReceiptManager let transactionManager: StoreKitTransactionManager let productsManager: StoreKitProductsManager - var sk2Purchaser: SK2Purchaser? + var purchaser: StorekitPurchaser? var sk1QueueManager: SK1QueueManager? package let observerMode: Bool - let variationIdStorage: VariationIdStorage + let purchasePayloadStorage: PurchasePayloadStorage init( configuration: AdaptyConfiguration, @@ -40,52 +42,79 @@ public final class Adapty: Sendable { self.httpFallbackSession = backend.createFallbackExecutor() self.httpConfigsSession = backend.createConfigsExecutor() - let productVendorIdsStorage = ProductVendorIdsStorage() - self.variationIdStorage = VariationIdStorage() + let productVendorIdsStorage = BackendProductInfoStorage() + await PurchasePayloadStorage.migration(for: ProfileStorage.userId) + self.purchasePayloadStorage = PurchasePayloadStorage() - if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { - self.receiptManager = StoreKitReceiptManager(session: httpSession) - self.transactionManager = SK2TransactionManager(session: httpSession) - self.productsManager = SK2ProductsManager(apiKeyPrefix: apiKeyPrefix, session: httpSession, storage: productVendorIdsStorage) - self.sk1QueueManager = nil - } else { - self.receiptManager = StoreKitReceiptManager(session: httpSession, refreshIfEmpty: true) - self.transactionManager = receiptManager - self.productsManager = SK1ProductsManager(apiKeyPrefix: apiKeyPrefix, session: httpSession, storage: productVendorIdsStorage) - self.sk1QueueManager = nil + if configuration.transactionFinishBehavior != .manual { + _ = await PurchasePayloadStorage.removeAllUnfinishedTransactionState() } - self.sharedProfileManager = restoreProfileManager(configuration) - if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { + self.receiptManager = StoreKitReceiptManager( + httpSession: httpSession, + refreshIfEmpty: false + ) + self.transactionManager = SK2TransactionManager( + httpSession: httpSession, + storage: purchasePayloadStorage + ) + let sk2ProductsManager = SK2ProductsManager( + apiKeyPrefix: apiKeyPrefix, + session: httpSession, + storage: productVendorIdsStorage + ) + self.productsManager = sk2ProductsManager + + self.sharedProfileManager = restoreProfileManager(configuration) + if !observerMode { - self.sk2Purchaser = SK2Purchaser.startObserving( - purchaseValidator: self, - productsManager: productsManager, - storage: variationIdStorage + self.purchaser = SK2Purchaser.startObserving( + transactionSynchronizer: self, + subscriptionOfferSigner: self, + sk2ProductsManager: sk2ProductsManager, + storage: purchasePayloadStorage ) self.sk1QueueManager = SK1QueueManager.startObserving( - purchaseValidator: self, - productsManager: productsManager, - storage: variationIdStorage + transactionSynchronizer: self, + subscriptionOfferSigner: self, + productsManager: sk2ProductsManager, + storage: purchasePayloadStorage ) } + } else { + self.receiptManager = StoreKitReceiptManager( + httpSession: httpSession, + refreshIfEmpty: true + ) + self.transactionManager = receiptManager + let sk1ProductsManager = SK1ProductsManager( + apiKeyPrefix: apiKeyPrefix, + session: httpSession, + storage: productVendorIdsStorage + ) + + self.productsManager = sk1ProductsManager + + self.sharedProfileManager = restoreProfileManager(configuration) + if observerMode { SK1TransactionObserver.startObserving( - purchaseValidator: self, - productsManager: productsManager + transactionSynchronizer: self, + sk1ProductsManager: sk1ProductsManager ) } else { self.sk1QueueManager = SK1QueueManager.startObserving( - purchaseValidator: self, - productsManager: productsManager, - storage: variationIdStorage + transactionSynchronizer: self, + subscriptionOfferSigner: self, + productsManager: sk1ProductsManager, + storage: purchasePayloadStorage ) + self.purchaser = sk1QueueManager } } - startSyncIPv4OnceIfNeeded() } @@ -97,7 +126,7 @@ public final class Adapty: Sendable { let profileId = profileStorage.profileId let customerUserId = configuration.customerUserId let appAccountToken = customerUserId != nil ? configuration.appAccountToken : nil - let oldAppAccountToken = profileStorage.getAppAccountToken() + let oldAppAccountToken = profileStorage.appAccountToken() profileStorage.setAppAccountToken(appAccountToken) if let profile = profileStorage.getProfile(withCustomerUserId: customerUserId) { @@ -110,13 +139,15 @@ public final class Adapty: Sendable { } } - return .creating( + let newUserId = AdaptyUserId( profileId: profileId, - withCustomerUserId: customerUserId, + customerId: customerUserId + ) + return .creating( + userId: newUserId, task: Task { try await createNewProfileOnServer( - profileId, - customerUserId, + newUserId, appAccountToken ) } @@ -124,13 +155,12 @@ public final class Adapty: Sendable { } private func createNewProfileOnServer( - _ profileId: String, - _ customerUserId: String?, + _ newUserId: AdaptyUserId, _ appAccountToken: UUID? ) async throws(AdaptyError) -> ProfileManager { var isFirstLoop = true - let analyticsDisabled = profileStorage.externalAnalyticsDisabled + let analyticsDisabled = (try? profileStorage.externalAnalyticsDisabled(for: newUserId)) ?? false var createdProfile: VH? while true { let meta = await Environment.Meta(includedAnalyticIds: !analyticsDisabled) @@ -147,8 +177,7 @@ public final class Adapty: Sendable { response = createdProfile } else { response = try await httpSession.createProfile( - profileId: profileId, - customerUserId: customerUserId, + userId: newUserId, appAccountToken: appAccountToken, parameters: AdaptyProfileParameters(analyticsDisabled: analyticsDisabled), environmentMeta: meta @@ -157,30 +186,26 @@ public final class Adapty: Sendable { } var crossPlacementState = CrossPlacementState.defaultForNewUser - if profileId != response.value.profileId { + if newUserId.isNotEqualProfileId(response) { crossPlacementState = try await httpSession.fetchCrossPlacementState( - profileId: response.value.profileId + userId: response.userId ) } return (response, crossPlacementState) }.result - guard case let .creating(creatingProfileId, creatingCustomerUserId, _) = sharedProfileManager, - profileId == creatingProfileId, - customerUserId == creatingCustomerUserId - else { - throw AdaptyError.profileWasChanged() + guard case let .creating(creatingUserId, _) = sharedProfileManager, newUserId == creatingUserId else { + throw .profileWasChanged() } switch result { case let .success((createdProfile, crossPlacementState)): - if profileId != createdProfile.value.profileId { - profileStorage.clearProfile(newProfileId: createdProfile.value.profileId) + if newUserId.isNotEqualProfileId(createdProfile) { + profileStorage.clearProfile(newProfile: createdProfile) + } else { + profileStorage.setIdentifiedProfile(createdProfile) } - profileStorage.setSyncedTransactions(false) - profileStorage.setProfile(createdProfile) - let manager = ProfileManager( storage: profileStorage, profile: createdProfile, @@ -207,37 +232,37 @@ extension Adapty { toCustomerUserId newCustomerUserId: String, withAppAccountToken newAppAccountToken: UUID? ) async throws(AdaptyError) { - let oldAppAccountToken = profileStorage.getAppAccountToken() + let oldAppAccountToken = profileStorage.appAccountToken() profileStorage.setAppAccountToken(newAppAccountToken) switch sharedProfileManager { - case .none: + case nil: break - case let .current(manager): - if manager.profile.value.customerUserId == newCustomerUserId { + if manager.userId.customerId == newCustomerUserId { guard let newAppAccountToken, newAppAccountToken != oldAppAccountToken else { return } } - - case let .creating(_, customerUserId, task): - if customerUserId == newCustomerUserId { + case let .creating(userId, task): + if userId.customerId == newCustomerUserId { guard let newAppAccountToken, newAppAccountToken != oldAppAccountToken else { _ = try await task.profileManager return } } - task.cancel() } - let profileId = profileStorage.profileId + let newUserId = AdaptyUserId( + profileId: profileStorage.profileId, + customerId: newCustomerUserId + ) + log.verbose("start identify \(newUserId) ") - let task = Task { try await createNewProfileOnServer(profileId, newCustomerUserId, newAppAccountToken) } + let task = Task { try await createNewProfileOnServer(newUserId, newAppAccountToken) } sharedProfileManager = .creating( - profileId: profileId, - withCustomerUserId: newCustomerUserId, + userId: newUserId, task: task ) @@ -246,28 +271,32 @@ extension Adapty { func logout() async throws(AdaptyError) { switch sharedProfileManager { - case .none: + case nil: break case let .current(manager): - if manager.profile.value.customerUserId == nil { - throw AdaptyError.unidentifiedUserLogout() + if manager.userId.isAnonymous { + throw .unidentifiedUserLogout() } - case let .creating(_, customerUserId, task): - if customerUserId == nil { + case let .creating(userId, task): + if userId.isAnonymous { _ = try await task.profileManager return } task.cancel() } - profileStorage.clearProfile(newProfileId: nil) + profileStorage.clearProfile() - let profileId = profileStorage.profileId + let newAnonymousUserId = AdaptyUserId( + profileId: profileStorage.profileId, + customerId: nil + ) + + log.verbose("logout \(newAnonymousUserId) ") - let task = Task { try await createNewProfileOnServer(profileId, nil, nil) } + let task = Task { try await createNewProfileOnServer(newAnonymousUserId, nil) } sharedProfileManager = .creating( - profileId: profileId, - withCustomerUserId: nil, + userId: newAnonymousUserId, task: task ) _ = try await task.profileManager @@ -277,7 +306,7 @@ extension Adapty { private extension ProfileManager { enum Shared { case current(ProfileManager) - case creating(profileId: String, withCustomerUserId: String?, task: Task) + case creating(userId: AdaptyUserId, task: Task) } } @@ -290,21 +319,21 @@ private extension Task where Success == ProfileManager { if let adaptyError = error as? AdaptyError { throw adaptyError } - throw AdaptyError.profileWasChanged() + throw .profileWasChanged() } } } } extension Adapty { - var customerUserId: String? { + var userId: AdaptyUserId? { switch sharedProfileManager { - case .none: - return nil + case nil: + nil case let .current(manager): - return manager.profile.value.customerUserId - case let .creating(_, customerUserId, _): - return customerUserId + manager.userId + case let .creating(userId, _): + userId } } @@ -316,26 +345,20 @@ extension Adapty { } } - func profileManager(with profileId: String) throws(AdaptyError) -> ProfileManager? { - guard let manager = profileManager else { return nil } - guard profileId == manager.profileId else { throw AdaptyError.profileWasChanged() } - return manager - } - - func tryProfileManagerOrNil(with profileId: String) -> ProfileManager? { + func profileManager(withProfileId userId: AdaptyUserId) throws(AdaptyError) -> ProfileManager? { guard let manager = profileManager else { return nil } - guard profileId == manager.profileId else { return nil } + guard manager.isEqualProfileId(userId) else { throw .profileWasChanged() } return manager } var createdProfileManager: ProfileManager { get async throws(AdaptyError) { switch sharedProfileManager { - case .none: - throw AdaptyError.notActivated() + case nil: + throw .notActivated() case let .current(manager): return manager - case let .creating(_, _, task): + case let .creating(_, task): do { return try await withTaskCancellationWithError(CancellationError()) { try await task.profileManager @@ -344,7 +367,7 @@ extension Adapty { if let adaptyError = error as? AdaptyError { throw adaptyError } - throw AdaptyError.profileWasChanged() + throw .profileWasChanged() } } } @@ -355,9 +378,9 @@ extension ProfileManager? { var orThrows: ProfileManager { get throws(AdaptyError) { switch self { - case .none: - throw AdaptyError.profileWasChanged() - case let .some(value): + case nil: + throw .profileWasChanged() + case let value?: value } } diff --git a/Sources/AdaptyDelegate.swift b/Sources/AdaptyDelegate.swift index 1d4eb0ddc..665446a57 100644 --- a/Sources/AdaptyDelegate.swift +++ b/Sources/AdaptyDelegate.swift @@ -23,12 +23,18 @@ public protocol AdaptyDelegate: AnyObject, Sendable { func onInstallationDetailsSuccess(_ details: AdaptyInstallationDetails) func onInstallationDetailsFail(error: AdaptyError) + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func onUnfinishedTransaction(_ adaptyUnfinishedTransaction: AdaptyUnfinishedTransaction) } public extension AdaptyDelegate { func shouldAddStorePayment(for _: AdaptyDeferredProduct) -> Bool { true } func onInstallationDetailsSuccess(_ details: AdaptyInstallationDetails) {} func onInstallationDetailsFail(error: AdaptyError) {} + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func onUnfinishedTransaction(_ adaptyUnfinishedTransaction: AdaptyUnfinishedTransaction) {} } extension Adapty { diff --git a/Sources/AdaptyViewConfiguration/Adapty+AdaptyViewConfiguration.swift b/Sources/AdaptyViewConfiguration/Adapty+AdaptyViewConfiguration.swift index 738d95b21..65fc8e5b2 100644 --- a/Sources/AdaptyViewConfiguration/Adapty+AdaptyViewConfiguration.swift +++ b/Sources/AdaptyViewConfiguration/Adapty+AdaptyViewConfiguration.swift @@ -23,9 +23,9 @@ extension Adapty { private func getViewConfiguration( paywall: AdaptyPaywall, loadTimeout: TaskDuration - ) async throws -> AdaptyViewConfiguration { + ) async throws(AdaptyError) -> AdaptyViewConfiguration { guard let container = paywall.viewConfiguration else { - throw AdaptyError.isNoViewConfigurationInPaywall() + throw .isNoViewConfigurationInPaywall() } let viewConfiguration: AdaptyViewSource = @@ -48,15 +48,15 @@ extension Adapty { Adapty.sendImageUrlsToObserver(viewConfiguration) - let extractLocaleTask = Task { + let extractLocaleTask: AdaptyResultTask = Task { do { - return try viewConfiguration.extractLocale() + return try .success(viewConfiguration.extractLocale()) } catch { - throw AdaptyError.decodingViewConfiguration(error) + return .failure(.decodingViewConfiguration(error)) } } - return try await extractLocaleTask.value + return try await extractLocaleTask.value.get() } private func restoreViewConfiguration(_ locale: AdaptyLocale, _ paywall: AdaptyPaywall) -> AdaptyViewSource? { @@ -80,7 +80,7 @@ extension Adapty { ) async throws(AdaptyError) -> AdaptyViewSource { let httpSession = httpSession let apiKeyPrefix = apiKeyPrefix - let isTestUser = profileManager?.profile.value.isTestUser ?? false + let isTestUser = profileManager?.isTestUser ?? false do { return try await withThrowingTimeout(loadTimeout - .milliseconds(500)) { @@ -97,7 +97,7 @@ extension Adapty { } } catch { guard error is TimeoutError else { - throw AdaptyError.unknown(error) + throw .unknown(error) } } diff --git a/Sources/AdaptyViewConfiguration/AdaptyImageUrlObserver.swift b/Sources/AdaptyViewConfiguration/AdaptyImageUrlObserver.swift index 9464c721e..231ad00e9 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyImageUrlObserver.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyImageUrlObserver.swift @@ -32,7 +32,7 @@ extension Adapty { Task { guard let observer = await holder.imageUrlObserver else { return } let urls = config.extractImageUrls(config.responseLocale) - guard !urls.isEmpty else { return } + guard urls.isNotEmpty else { return } observer.extractedImageUrls(urls) } } diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource+getLocalization.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource+getLocalization.swift index ca871cda1..d9f583556 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource+getLocalization.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource+getLocalization.swift @@ -46,8 +46,8 @@ private extension AdaptyViewSource.Localization { return .init( id: id, isRightToLeft: isRightToLeft ?? localization.isRightToLeft, - strings: strings.isEmpty ? nil : strings, - assets: assets.isEmpty ? nil : assets + strings: strings.nonEmptyOrNil, + assets: assets.nonEmptyOrNil ) } } diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource.swift index 9911b3300..2ab3ee4c1 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/AdaptyViewSource.swift @@ -104,7 +104,7 @@ extension AdaptyViewSource: Codable { try container.encode(templateRevision, forKey: .templateRevision) try container.encode(formatVersion, forKey: .formatVersion) - if !selectedProducts.isEmpty { + if selectedProducts.isNotEmpty { var products = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .products) try products.encode(selectedProducts, forKey: .selected) } diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.Localization.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.Localization.swift index 593660084..067f4ded4 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.Localization.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.Localization.swift @@ -54,7 +54,7 @@ extension AdaptyViewSource.Localization: Codable { fallback: item.decodeIfPresent(AdaptyViewSource.RichText.self, forKey: .fallback) ) } - self.strings = strings.isEmpty ? nil : strings + self.strings = strings.nonEmptyOrNil } func encode(to encoder: any Encoder) throws { @@ -62,7 +62,7 @@ extension AdaptyViewSource.Localization: Codable { try container.encode(id, forKey: .id) try container.encodeIfPresent(isRightToLeft, forKey: .isRightToLeft) - if let assets, !assets.isEmpty { + if let assets = assets.nonEmptyOrNil { try container.encode(AdaptyViewSource.AssetsContainer(value: assets), forKey: .assets) } diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.RichText.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.RichText.swift index c639fe0b8..06aee5a64 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.RichText.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Localization/VC.RichText.swift @@ -9,7 +9,7 @@ import Foundation extension AdaptyViewSource { - struct TextAttributes: Sendable, Hashable { + struct TextAttributes: Sendable, Hashable, Emptiable { let fontAssetId: String? let size: Double? let txtColorAssetId: String? @@ -29,7 +29,7 @@ extension AdaptyViewSource { } } - struct RichText: Sendable, Hashable { + struct RichText: Sendable, Hashable, Emptiable { let items: [RichText.Item] var isEmpty: Bool { items.isEmpty } @@ -115,9 +115,9 @@ private extension AdaptyViewSource.TextAttributes? { _ other: AdaptyViewSource.TextAttributes? ) -> AdaptyViewSource.TextAttributes? { switch self { - case .none: + case nil: other - case let .some(value): + case let value?: value.add(other) } } @@ -221,7 +221,7 @@ extension AdaptyViewSource.RichText.Item: Codable { func encode(to encoder: any Encoder) throws { switch self { case let .text(text, attributes): - guard let attributes, !attributes.isEmpty else { + guard let attributes = attributes.nonEmptyOrNil else { var container = encoder.singleValueContainer() try container.encode(text) return @@ -232,13 +232,13 @@ extension AdaptyViewSource.RichText.Item: Codable { case let .tag(tag, attributes): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(tag, forKey: .tag) - if let attributes, !attributes.isEmpty { + if let attributes = attributes.nonEmptyOrNil { try container.encode(attributes, forKey: .attributes) } case let .image(image, attributes): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(image, forKey: .image) - if let attributes, !attributes.isEmpty { + if let attributes = attributes.nonEmptyOrNil { try container.encode(attributes, forKey: .attributes) } case .unknown: diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Action.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Action.swift index 91e8ef22a..91afd0cf3 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Action.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Action.swift @@ -70,7 +70,7 @@ extension AdaptyViewSource.Action: Decodable { package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) switch try Types(rawValue: container.decode(String.self, forKey: .type)) { - case .none: + case nil: throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: "unknown value")) case .openUrl: self = try .openUrl(container.decode(String.self, forKey: .url)) diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Animation.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Animation.swift index 37e5f02fd..ffea29b12 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Animation.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Animation.swift @@ -86,7 +86,7 @@ extension AdaptyViewSource.Animation: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) let typeName = try container.decode(String.self, forKey: .type) switch Types(rawValue: typeName) { - case .none: + case nil: throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Unknown animation type with name \(typeName)'")) case .fade: self = try .opacity(.init(from: decoder), .init(start: 0.0, end: 1.0)) diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Text.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Text.swift index 3c402a05d..176e5f793 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Text.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Text.swift @@ -74,11 +74,11 @@ extension AdaptyViewSource.Text: Codable { try Set(container.decodeIfPresent([AdaptyViewConfiguration.Text.OverflowMode].self, forKey: .overflowMode) ?? []) } let textAttributes = try AdaptyViewSource.TextAttributes(from: decoder) - defaultTextAttributes = textAttributes.isEmpty ? nil : textAttributes + defaultTextAttributes = textAttributes.nonEmptyOrNil } func encode(to encoder: any Encoder) throws { - if let defaultTextAttributes, !defaultTextAttributes.isEmpty { + if let defaultTextAttributes = defaultTextAttributes.nonEmptyOrNil { try defaultTextAttributes.encode(to: encoder) } var container = encoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Timer.swift b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Timer.swift index 200106f5e..c2160b31d 100644 --- a/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Timer.swift +++ b/Sources/AdaptyViewConfiguration/AdaptyViewSource/Screen/VC.Timer.swift @@ -78,7 +78,7 @@ extension AdaptyViewSource.Timer: Decodable { try .endedAt(container.decode(AdaptyViewSource.DateString.self, forKey: .endTime).utc) case BehaviorType.endAtLocalTime.rawValue: try .endedAt(container.decode(AdaptyViewSource.DateString.self, forKey: .endTime).local) - case .none: + case nil: try .duration(container.decode(TimeInterval.self, forKey: .duration), start: .default) case BehaviorType.everyAppear.rawValue: try .duration(container.decode(TimeInterval.self, forKey: .duration), start: .everyAppear) @@ -89,7 +89,7 @@ extension AdaptyViewSource.Timer: Decodable { case BehaviorType.custom.rawValue: try .duration(container.decode(TimeInterval.self, forKey: .duration), start: .custom) default: - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [CodingKeys.behavior], debugDescription: "unknown value '\(behavior ?? "null")'")) + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [CodingKeys.behavior], debugDescription: "unknown value '\(behavior ?? "nil")'")) } format = @@ -108,7 +108,7 @@ extension AdaptyViewSource.Timer: Decodable { horizontalAlign = try container.decodeIfPresent(AdaptyViewConfiguration.HorizontalAlignment.self, forKey: .horizontalAlign) ?? .leading let textAttributes = try AdaptyViewSource.TextAttributes(from: decoder) - defaultTextAttributes = textAttributes.isEmpty ? nil : textAttributes + defaultTextAttributes = textAttributes.nonEmptyOrNil } } diff --git a/Sources/AdaptyViewConfiguration/Entities/Animation.Interpolator.swift b/Sources/AdaptyViewConfiguration/Entities/Animation.Interpolator.swift index 727fed632..6ae205a6f 100644 --- a/Sources/AdaptyViewConfiguration/Entities/Animation.Interpolator.swift +++ b/Sources/AdaptyViewConfiguration/Entities/Animation.Interpolator.swift @@ -84,7 +84,7 @@ extension AdaptyViewConfiguration.Animation.Interpolator: Codable { let value = try container.decode(String.self) switch Values(rawValue: value) { - case .none: + case nil: throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Interpolator name \(value)'")) case .easeInOut: self = .easeInOut diff --git a/Sources/AdaptyViewConfiguration/Entities/ShapeType.swift b/Sources/AdaptyViewConfiguration/Entities/ShapeType.swift index 9b8ebb377..9fb094efa 100644 --- a/Sources/AdaptyViewConfiguration/Entities/ShapeType.swift +++ b/Sources/AdaptyViewConfiguration/Entities/ShapeType.swift @@ -43,7 +43,7 @@ extension AdaptyViewConfiguration.ShapeType: Codable { package init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() switch try Types(rawValue: container.decode(String.self)) { - case .none: + case nil: throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "unknown value")) case .curveUp: self = .curveUp diff --git a/Sources/AdaptyViewConfiguration/Entities/StateCondition.swift b/Sources/AdaptyViewConfiguration/Entities/StateCondition.swift index cf60af022..1fefbbde6 100644 --- a/Sources/AdaptyViewConfiguration/Entities/StateCondition.swift +++ b/Sources/AdaptyViewConfiguration/Entities/StateCondition.swift @@ -46,7 +46,7 @@ extension AdaptyViewConfiguration.StateCondition: Codable { package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) switch try Types(rawValue: container.decode(String.self, forKey: .type)) { - case .none: + case nil: throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: "unknown value")) case .selectedSection: self = try .selectedSection( diff --git a/Sources/AdaptyViewConfiguration/Entities/Unit.swift b/Sources/AdaptyViewConfiguration/Entities/Unit.swift index 129b6d497..7d1885b99 100644 --- a/Sources/AdaptyViewConfiguration/Entities/Unit.swift +++ b/Sources/AdaptyViewConfiguration/Entities/Unit.swift @@ -72,12 +72,12 @@ extension AdaptyViewConfiguration.Unit: Codable { let value = try container.decode(Double.self, forKey: .value) let unit = try container.decodeIfPresent(String.self, forKey: .unit) switch unit { - case .some(CodingKeys.screen.rawValue): + case CodingKeys.screen.rawValue: self = .screen(value) - case .some(CodingKeys.point.rawValue), .none: + case CodingKeys.point.rawValue, nil: self = .point(value) default: - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [CodingKeys.unit], debugDescription: "usupport value: \(unit ?? "null")")) + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [CodingKeys.unit], debugDescription: "usupport value: \(unit ?? "nil")")) } } } diff --git a/Sources/Backend.HTTPSession/Log/HTTPMetrics.swift b/Sources/Backend.HTTPSession/Log/HTTPMetrics.swift index 83b36a9c9..86a256627 100644 --- a/Sources/Backend.HTTPSession/Log/HTTPMetrics.swift +++ b/Sources/Backend.HTTPSession/Log/HTTPMetrics.swift @@ -92,7 +92,7 @@ extension HTTPMetrics: Encodable { if redirectCount > 0 { try container.encode(redirectCount, forKey: .redirect) } - if !transactions.isEmpty { + if transactions.isNotEmpty { try container.encode(transactions, forKey: .transactions) } if decoding > 0 { @@ -144,7 +144,7 @@ extension HTTPMetrics: CustomDebugStringConvertible { if redirectCount > 0 { result += "\tredirects: \(redirectCount)" } - if !transactions.isEmpty { + if transactions.isNotEmpty { result += "\t\t " + transactions.map { $0.debugDescription }.joined(separator: ", ") } if decoding > 1 { diff --git a/Sources/Backend.HTTPSession/Log/HTTPSession.Log.swift b/Sources/Backend.HTTPSession/Log/HTTPSession.Log.swift index 2b9f70177..008ca23d9 100644 --- a/Sources/Backend.HTTPSession/Log/HTTPSession.Log.swift +++ b/Sources/Backend.HTTPSession/Log/HTTPSession.Log.swift @@ -30,7 +30,8 @@ extension Log { static func responseError(_ error: HTTPError, request: URLRequest, stamp: String, response: HTTPDataResponse?) { if case let .network(_, _, _, error: originalError) = error, - originalError.isNetworkConnectionError { + originalError.isNetworkConnectionError + { log.verbose("NO CONNECTION <-- \(error.endpoint.method) \(error.endpoint.pathAsLogString(request.url)) [\(stamp)] -- \(error)\(error.metrics?.debugDescription ?? "")") } else if error.isCancelled { log.verbose("CANCELED <-- \(error.endpoint.method) \(error.endpoint.pathAsLogString(request.url)) [\(stamp)]\(error.metrics?.debugDescription ?? "")") @@ -68,7 +69,7 @@ private extension HTTPDataResponse { private extension Data? { var asLogString: String { - guard let data = self, let str = String(data: data, encoding: .utf8), !str.isEmpty else { return "" } + guard let data = self, let str = String(data: data, encoding: .utf8).nonEmptyOrNil else { return "" } return " -d '\(str)'" } } diff --git a/Sources/Backend.HTTPSession/Log/URLRequest+Curl.swift b/Sources/Backend.HTTPSession/Log/URLRequest+Curl.swift index c28fbea05..b9fc6fb67 100644 --- a/Sources/Backend.HTTPSession/Log/URLRequest+Curl.swift +++ b/Sources/Backend.HTTPSession/Log/URLRequest+Curl.swift @@ -49,7 +49,8 @@ extension URLRequest { if let session, session.httpShouldSetCookies { if let cookieStorage = session.httpCookieStorage, - let cookies = cookieStorage.cookies(for: url), !cookies.isEmpty { + let cookies = cookieStorage.cookies(for: url).nonEmptyOrNil + { let allCookies = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: ";") cookieString = String(format: CurlParameters.cookies, allCookies) diff --git a/Sources/Backend.HTTPSession/Request/HTTPRequest+URLRequest.swift b/Sources/Backend.HTTPSession/Request/HTTPRequest+URLRequest.swift index ac1404194..b275b002e 100644 --- a/Sources/Backend.HTTPSession/Request/HTTPRequest+URLRequest.swift +++ b/Sources/Backend.HTTPSession/Request/HTTPRequest+URLRequest.swift @@ -24,7 +24,7 @@ extension HTTPRequest { queryItems.append(contentsOf: additionalQueryItems) } - if !queryItems.isEmpty { + if queryItems.isNotEmpty { urlComponents.queryItems = queryItems } @@ -54,7 +54,8 @@ extension HTTPRequest { if let contentType = dataRequest.contentType ?? (configuration as? HTTPCodableConfiguration)?.defaultEncodedContentType, - request.value(forHTTPHeaderField: HeaderKey.contentType)?.isEmpty ?? true { + request.value(forHTTPHeaderField: HeaderKey.contentType).isEmpty + { request.setValue(contentType, forHTTPHeaderField: HeaderKey.contentType) } } diff --git a/Sources/Backend.HTTPSession/Request/HTTPRequest.QueryItems.swift b/Sources/Backend.HTTPSession/Request/HTTPRequest.QueryItems.swift index cf8df8d71..d3aa0be52 100644 --- a/Sources/Backend.HTTPSession/Request/HTTPRequest.QueryItems.swift +++ b/Sources/Backend.HTTPSession/Request/HTTPRequest.QueryItems.swift @@ -22,7 +22,7 @@ extension URLQueryItem { } init(name: String, values: [some CustomStringConvertible]?) { - guard let array = values, !array.isEmpty else { + guard let array = values.nonEmptyOrNil else { self.init(name: name, value: nil) return } @@ -36,10 +36,6 @@ extension [HTTPRequest.QueryItems.Element] { filter { $0.value != nil } } - func emptyToNil() -> Self? { - isEmpty ? nil : self - } - init(key: String, array: [some CustomStringConvertible]?) { guard let array else { self = [] diff --git a/Sources/Backend/Backend+Codable.swift b/Sources/Backend/Backend+Codable.swift index 62863b96c..37749085e 100644 --- a/Sources/Backend/Backend+Codable.swift +++ b/Sources/Backend/Backend+Codable.swift @@ -38,7 +38,7 @@ extension Backend { private extension CodingUserInfoKey { static let enableEncodingViewConfiguration = CodingUserInfoKey(rawValue: "adapty_encode_view_configuration")! - static let profileId = CodingUserInfoKey(rawValue: "adapty_profile_id")! + static let userId = CodingUserInfoKey(rawValue: "adapty_user_id")! static let placementId = CodingUserInfoKey(rawValue: "adapty_placement_id")! static let placementVariationId = CodingUserInfoKey(rawValue: "adapty_placement_variation_id")! static let placement = CodingUserInfoKey(rawValue: "adapty_placement")! @@ -46,8 +46,8 @@ private extension CodingUserInfoKey { } extension CodingUserInfo { - mutating func setProfileId(_ value: String) { - self[.profileId] = value + mutating func setUserId(_ value: AdaptyUserId) { + self[.userId] = value } mutating func setPlacement(_ value: AdaptyPlacement) { @@ -76,13 +76,13 @@ extension [CodingUserInfoKey: Any] { self[.enableEncodingViewConfiguration] as? Bool ?? false } - var profileId: String { + var userId: AdaptyUserId { get throws { - if let value = self[.profileId] as? String { + if let value = self[.userId] as? AdaptyUserId { return value } - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The decoder does not have the \(CodingUserInfoKey.profileId) parameter")) + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The decoder does not have the \(CodingUserInfoKey.userId) parameter")) } } diff --git a/Sources/Backend/Backend.Headers.swift b/Sources/Backend/Backend.Headers.swift index 2cca88c84..56d4f3a27 100644 --- a/Sources/Backend/Backend.Headers.swift +++ b/Sources/Backend/Backend.Headers.swift @@ -105,8 +105,8 @@ extension HTTPHeaders { updateOrRemoveValue(hash, forKey: Backend.Request.hashHeaderKey) } - func setBackendProfileId(_ profileId: String?) -> Self { - updateOrRemoveValue(profileId, forKey: Backend.Request.profileIdHeaderKey) + func setUserProfileId(_ userId: AdaptyUserId?) -> Self { + updateOrRemoveValue(userId?.profileId, forKey: Backend.Request.profileIdHeaderKey) } private func updateOrRemoveValue(_ value: String?, forKey key: String) -> Self { diff --git a/Sources/Backend/Backend.QueryItems.swift b/Sources/Backend/Backend.QueryItems.swift index 0051dbdbf..efc2f40f2 100644 --- a/Sources/Backend/Backend.QueryItems.swift +++ b/Sources/Backend/Backend.QueryItems.swift @@ -15,11 +15,11 @@ private extension Backend.Request { } extension [HTTPRequest.QueryItems.Element] { - func setBackendProfileId(_ profileId: String?) -> Self { + func setUserProfileId(_ userId: AdaptyUserId?) -> Self { var queryItems = filter { $0.name != Backend.Request.profileIdQueryItemName } - if let profileId { - queryItems.append(URLQueryItem(name: Backend.Request.profileIdQueryItemName, value: profileId)) + if let userId { + queryItems.append(URLQueryItem(name: Backend.Request.profileIdQueryItemName, value: userId.profileId)) } return queryItems } diff --git a/Sources/Backend/Backend.Validator.swift b/Sources/Backend/Backend.Validator.swift index 7a418e5d7..18de6d7e6 100644 --- a/Sources/Backend/Backend.Validator.swift +++ b/Sources/Backend/Backend.Validator.swift @@ -36,7 +36,7 @@ extension Backend { let container = try decoder.container(keyedBy: CodingKeys.self) if let code = try container.decodeIfPresent(String.self, forKey: .code) { self.codes = [code] - } else if let details = try container.decodeIfPresent([Detail].self, forKey: .details), !details.isEmpty { + } else if let details = try container.decodeIfPresent([Detail].self, forKey: .details).nonEmptyOrNil { self.codes = details.map(\.value) } else { let array = try container.decodeIfPresent([Code].self, forKey: .array) @@ -56,8 +56,8 @@ extension Backend { @Sendable func validator(_ response: HTTPDataResponse) -> Error? { - guard let data = response.body, !data.isEmpty, - let errorCodes = try? errorCodesResponse(from: data, withConfiguration: self).codes, !errorCodes.isEmpty + guard let data = response.body.nonEmptyOrNil, + let errorCodes = try? errorCodesResponse(from: data, withConfiguration: self).codes.nonEmptyOrNil else { return HTTPResponse.statusCodeValidator(response) } diff --git a/Sources/Backend/EventsRequests/FetchEventsConfigRequest.swift b/Sources/Backend/EventsRequests/FetchEventsConfigRequest.swift index bc98a8179..4ed5a4b46 100644 --- a/Sources/Backend/EventsRequests/FetchEventsConfigRequest.swift +++ b/Sources/Backend/EventsRequests/FetchEventsConfigRequest.swift @@ -17,17 +17,17 @@ private struct FetchEventsConfigRequest: HTTPRequestWithDecodableResponse { let headers: HTTPHeaders let stamp = Log.stamp - init(profileId: String) { - headers = HTTPHeaders().setBackendProfileId(profileId) + init(userId: AdaptyUserId) { + headers = HTTPHeaders().setUserProfileId(userId) } } extension Backend.EventsExecutor { func fetchEventsConfig( - profileId: String + userId: AdaptyUserId ) async throws(HTTPError) -> EventsBackendConfiguration { let request = FetchEventsConfigRequest( - profileId: profileId + userId: userId ) let response = try await perform( diff --git a/Sources/Backend/EventsRequests/SendEventsRequest.swift b/Sources/Backend/EventsRequests/SendEventsRequest.swift index dc4c5c086..1e80a8f73 100644 --- a/Sources/Backend/EventsRequests/SendEventsRequest.swift +++ b/Sources/Backend/EventsRequests/SendEventsRequest.swift @@ -17,8 +17,8 @@ struct SendEventsRequest: HTTPDataRequest { let events: [Data] - init(profileId: String, events: [Data]) { - headers = HTTPHeaders().setBackendProfileId(profileId) + init(userId: AdaptyUserId, events: [Data]) { + headers = HTTPHeaders().setUserProfileId(userId) self.events = events } @@ -50,12 +50,12 @@ struct SendEventsRequest: HTTPDataRequest { extension Backend.EventsExecutor { func sendEvents( - profileId: String, + userId: AdaptyUserId, events: [Data] ) async throws(EventsError) { do { let _: HTTPEmptyResponse = try await session.perform(SendEventsRequest( - profileId: profileId, + userId: userId, events: events )) } catch { diff --git a/Sources/Backend/MainRequests/CreateProfileRequest.swift b/Sources/Backend/MainRequests/CreateProfileRequest.swift index d72341188..05d9b90cd 100644 --- a/Sources/Backend/MainRequests/CreateProfileRequest.swift +++ b/Sources/Backend/MainRequests/CreateProfileRequest.swift @@ -14,29 +14,28 @@ private struct CreateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda let headers: HTTPHeaders let stamp = Log.stamp - let profileId: String - let parameters: AdaptyProfileParameters? - let customerUserId: String? + let userId: AdaptyUserId let appAccountToken: UUID? + let parameters: AdaptyProfileParameters? let environmentMeta: Environment.Meta init( - profileId: String, - customerUserId: String?, + userId: AdaptyUserId, appAccountToken: UUID?, parameters: AdaptyProfileParameters?, environmentMeta: Environment.Meta ) { endpoint = HTTPEndpoint( method: .post, - path: "/sdk/analytics/profiles/\(profileId)/" + path: "/sdk/analytics/profiles/\(userId.profileId)/" ) - headers = HTTPHeaders().setBackendProfileId(profileId) - self.profileId = profileId - self.parameters = parameters - self.customerUserId = customerUserId + headers = HTTPHeaders() + .setUserProfileId(userId) + + self.userId = userId self.appAccountToken = appAccountToken + self.parameters = parameters self.environmentMeta = environmentMeta } @@ -53,14 +52,14 @@ private struct CreateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda var container = encoder.container(keyedBy: Backend.CodingKeys.self) var dataObject = container.nestedContainer(keyedBy: Backend.CodingKeys.self, forKey: .data) try dataObject.encode("adapty_analytics_profile", forKey: .type) - try dataObject.encode(profileId, forKey: .id) + try dataObject.encode(userId.profileId, forKey: .id) if let parameters { try dataObject.encode(parameters, forKey: .attributes) } var attributesObject = dataObject.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) - try attributesObject.encodeIfPresent(customerUserId, forKey: .customerUserId) + try attributesObject.encodeIfPresent(userId.customerId, forKey: .customerUserId) try attributesObject.encodeIfPresent(appAccountToken?.uuidString.lowercased(), forKey: .appAccountToken) try attributesObject.encode(environmentMeta, forKey: .environmentMeta) if parameters?.storeCountry == nil { @@ -78,15 +77,13 @@ private struct CreateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda extension Backend.MainExecutor { func createProfile( - profileId: String, - customerUserId: String?, + userId: AdaptyUserId, appAccountToken: UUID?, parameters: AdaptyProfileParameters?, environmentMeta: Environment.Meta ) async throws(HTTPError) -> VH { let request = CreateProfileRequest( - profileId: profileId, - customerUserId: customerUserId, + userId: userId, appAccountToken: appAccountToken, parameters: parameters, environmentMeta: environmentMeta @@ -96,7 +93,7 @@ extension Backend.MainExecutor { request, requestName: .createProfile, logParams: [ - "customer_user_id": customerUserId, + "customer_user_id": userId.customerId, "app_account_token": appAccountToken?.uuidString ] ) diff --git a/Sources/Backend/MainRequests/FetchAllProductVendorIdsRequest.swift b/Sources/Backend/MainRequests/FetchAllProductInfoRequest.swift similarity index 52% rename from Sources/Backend/MainRequests/FetchAllProductVendorIdsRequest.swift rename to Sources/Backend/MainRequests/FetchAllProductInfoRequest.swift index 9f1a9cc7b..cb056aca4 100644 --- a/Sources/Backend/MainRequests/FetchAllProductVendorIdsRequest.swift +++ b/Sources/Backend/MainRequests/FetchAllProductInfoRequest.swift @@ -1,5 +1,5 @@ // -// FetchAllProductVendorIdsRequest.swift +// FetchAllProductInfoRequest.swift // AdaptySDK // // Created by Aleksei Valiano on 23.09.2022. @@ -7,29 +7,29 @@ import Foundation -private struct FetchAllProductVendorIdsRequest: HTTPRequestWithDecodableResponse { - typealias ResponseBody = Backend.Response.Data<[String]> +private struct FetchAllProductInfoRequest: HTTPRequestWithDecodableResponse { + typealias ResponseBody = Backend.Response.Data<[BackendProductInfo]> let endpoint: HTTPEndpoint let stamp = Log.stamp init(apiKeyPrefix: String) { endpoint = HTTPEndpoint( method: .get, - path: "/sdk/in-apps/\(apiKeyPrefix)/products-ids/app_store/" + path: "/sdk/in-apps/\(apiKeyPrefix)/products/app_store/" ) } } extension Backend.MainExecutor { - func fetchAllProductVendorIds( + func fetchProductInfo( apiKeyPrefix: String - ) async throws(HTTPError) -> [String] { - let request = FetchAllProductVendorIdsRequest( + ) async throws(HTTPError) -> [BackendProductInfo] { + let request = FetchAllProductInfoRequest( apiKeyPrefix: apiKeyPrefix ) let response = try await perform( request, - requestName: .fetchAllProductVendorIds + requestName: .fetchAllProductInfo ) return response.body.value diff --git a/Sources/Backend/MainRequests/FetchCrossPlacementStateRequest.swift b/Sources/Backend/MainRequests/FetchCrossPlacementStateRequest.swift index 1271a6702..33e660b38 100644 --- a/Sources/Backend/MainRequests/FetchCrossPlacementStateRequest.swift +++ b/Sources/Backend/MainRequests/FetchCrossPlacementStateRequest.swift @@ -16,18 +16,18 @@ private struct FetchCrossPlacementStateRequest: HTTPRequestWithDecodableResponse let headers: HTTPHeaders let stamp = Log.stamp - init(profileId: String) { + init(userId: AdaptyUserId) { headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) } } extension Backend.MainExecutor { func fetchCrossPlacementState( - profileId: String + userId: AdaptyUserId ) async throws(HTTPError) -> CrossPlacementState { let request = FetchCrossPlacementStateRequest( - profileId: profileId + userId: userId ) let response = try await perform( diff --git a/Sources/Backend/MainRequests/FetchIntroductoryOfferEligibilityRequest.swift b/Sources/Backend/MainRequests/FetchIntroductoryOfferEligibilityRequest.swift index cf4083067..3b6729fa1 100644 --- a/Sources/Backend/MainRequests/FetchIntroductoryOfferEligibilityRequest.swift +++ b/Sources/Backend/MainRequests/FetchIntroductoryOfferEligibilityRequest.swift @@ -28,11 +28,13 @@ private struct FetchIntroductoryOfferEligibilityRequest: HTTPRequestWithDecodabl ) } - init(profileId: String, responseHash: String?) { + init(userId: AdaptyUserId, responseHash: String?) { headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setBackendResponseHash(responseHash) - queryItems = QueryItems().setBackendProfileId(profileId) + + queryItems = QueryItems() + .setUserProfileId(userId) } } @@ -60,11 +62,11 @@ extension HTTPRequestWithDecodableResponse where ResponseBody == [BackendIntrodu extension Backend.MainExecutor { func fetchIntroductoryOfferEligibility( - profileId: String, + userId: AdaptyUserId, responseHash: String? - ) async throws(HTTPError) -> VH<[BackendIntroductoryOfferEligibilityState]?> { + ) async throws(HTTPError) -> VH<[BackendIntroductoryOfferEligibilityState]> { let request = FetchIntroductoryOfferEligibilityRequest( - profileId: profileId, + userId: userId, responseHash: responseHash ) @@ -72,6 +74,6 @@ extension Backend.MainExecutor { request, requestName: .fetchProductStates ) - return VH(response.body, hash: response.headers.getBackendResponseHash()) + return VH(response.body ?? [], hash: response.headers.getBackendResponseHash()) } } diff --git a/Sources/Backend/MainRequests/FetchPlacementRequest.swift b/Sources/Backend/MainRequests/FetchPlacementRequest.swift index 126249be9..266a31297 100644 --- a/Sources/Backend/MainRequests/FetchPlacementRequest.swift +++ b/Sources/Backend/MainRequests/FetchPlacementRequest.swift @@ -18,7 +18,7 @@ private struct FetchPaywallRequest: HTTPRequest, BackendAPIRequestParameters { init( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, variationId: String, locale: AdaptyLocale, @@ -33,7 +33,7 @@ private struct FetchPaywallRequest: HTTPRequest, BackendAPIRequestParameters { headers = HTTPHeaders() .setPaywallLocale(locale) - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setPaywallBuilderVersion(AdaptyViewConfiguration.builderVersion) .setPaywallBuilderConfigurationFormatVersion(AdaptyViewConfiguration.formatVersion) @@ -63,7 +63,7 @@ private struct FetchOnboardingRequest: HTTPRequest, BackendAPIRequestParameters init( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, variationId: String, locale: AdaptyLocale, @@ -78,7 +78,7 @@ private struct FetchOnboardingRequest: HTTPRequest, BackendAPIRequestParameters headers = HTTPHeaders() .setOnboardingLocale(locale) - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setOnboardingUIVersion(AdaptyOnboarding.ViewConfiguration.uiVersion) queryItems = QueryItems().setDisableServerCache(disableServerCache) @@ -99,7 +99,7 @@ extension AdaptyPlacementChosen { static func decodePlacementResponse( _ response: HTTPDataResponse, withConfiguration configuration: HTTPCodableConfiguration?, - withProfileId profileId: String, + withUserId userId: AdaptyUserId, withRequestLocale requestLocale: AdaptyLocale, withCached cached: Content? ) async throws -> HTTPResponse { @@ -111,7 +111,7 @@ extension AdaptyPlacementChosen { responseBody: response.body ).value - if let cached, cached.placement.version > placement.version { + if let cached, cached.placement.isNewerThan(placement) { return response.replaceBody(AdaptyPlacementChosen.restore(cached)) } @@ -129,7 +129,7 @@ extension AdaptyPlacementChosen { ).value let draw = AdaptyPlacement.Draw( - profileId: profileId, + userId: userId, content: content, placementAudienceVersionId: placement.audienceVersionId, variationIdByPlacements: variation.variationIdByPlacements @@ -142,7 +142,7 @@ extension AdaptyPlacementChosen { extension Backend.MainExecutor { func fetchPlacement( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, variationId: String, locale: AdaptyLocale, @@ -153,7 +153,7 @@ extension Backend.MainExecutor { if Content.self == AdaptyPaywall.self { FetchPaywallRequest( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, variationId: variationId, locale: locale, @@ -163,7 +163,7 @@ extension Backend.MainExecutor { } else { FetchOnboardingRequest( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, variationId: variationId, locale: locale, @@ -177,7 +177,7 @@ extension Backend.MainExecutor { try await AdaptyPlacementChosen.decodePlacementResponse( response, withConfiguration: configuration, - withProfileId: profileId, + withUserId: userId, withRequestLocale: locale, withCached: cached ) diff --git a/Sources/Backend/MainRequests/FetchPlacementVariationsRequest.swift b/Sources/Backend/MainRequests/FetchPlacementVariationsRequest.swift index debc641a6..bf63f05e9 100644 --- a/Sources/Backend/MainRequests/FetchPlacementVariationsRequest.swift +++ b/Sources/Backend/MainRequests/FetchPlacementVariationsRequest.swift @@ -18,7 +18,7 @@ private struct FetchPaywallVariationsRequest: HTTPRequest, BackendAPIRequestPara init( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, locale: AdaptyLocale, segmentId: String, @@ -34,7 +34,7 @@ private struct FetchPaywallVariationsRequest: HTTPRequest, BackendAPIRequestPara headers = HTTPHeaders() .setPaywallLocale(locale) - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setPaywallBuilderVersion(AdaptyViewConfiguration.builderVersion) .setPaywallBuilderConfigurationFormatVersion(AdaptyViewConfiguration.formatVersion) .setCrossPlacementEligibility(crossPlacementEligible) @@ -67,7 +67,7 @@ private struct FetchOnboardingVariationsRequest: HTTPRequest, BackendAPIRequestP init( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, locale: AdaptyLocale, segmentId: String, @@ -83,12 +83,13 @@ private struct FetchOnboardingVariationsRequest: HTTPRequest, BackendAPIRequestP headers = HTTPHeaders() .setOnboardingLocale(locale) - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setOnboardingUIVersion(AdaptyOnboarding.ViewConfiguration.uiVersion) .setCrossPlacementEligibility(crossPlacementEligible) .setSegmentId(segmentId) - queryItems = QueryItems().setDisableServerCache(disableServerCache) + queryItems = QueryItems() + .setDisableServerCache(disableServerCache) logParams = [ "api_prefix": apiKeyPrefix, @@ -109,7 +110,7 @@ extension AdaptyPlacementChosen { static func decodePlacementVariationsResponse( _ response: HTTPDataResponse, withConfiguration configuration: HTTPCodableConfiguration?, - withProfileId profileId: String, + withUserId userId: AdaptyUserId, withPlacementId placementId: String, withRequestLocale requestLocale: AdaptyLocale, withCached cached: Content?, @@ -123,12 +124,12 @@ extension AdaptyPlacementChosen { responseBody: response.body ).value - if let cached, cached.placement.version > placement.version { + if let cached, cached.placement.isNewerThan(placement) { return response.replaceBody(AdaptyPlacementChosen.restore(cached)) } jsonDecoder.userInfo.setPlacement(placement) - jsonDecoder.userInfo.setProfileId(profileId) + jsonDecoder.userInfo.setUserId(userId) jsonDecoder.userInfo.setRequestLocale(requestLocale) let draw = try jsonDecoder.decode( @@ -159,7 +160,7 @@ extension AdaptyPlacementChosen { extension Backend.MainExecutor { func fetchPlacementVariations( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, locale: AdaptyLocale, segmentId: String, @@ -172,7 +173,7 @@ extension Backend.MainExecutor { if Content.self == AdaptyPaywall.self { FetchPaywallVariationsRequest( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, segmentId: segmentId, @@ -183,7 +184,7 @@ extension Backend.MainExecutor { } else { FetchOnboardingVariationsRequest( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, segmentId: segmentId, @@ -198,7 +199,7 @@ extension Backend.MainExecutor { try await AdaptyPlacementChosen.decodePlacementVariationsResponse( response, withConfiguration: configuration, - withProfileId: profileId, + withUserId: userId, withPlacementId: placementId, withRequestLocale: locale, withCached: cached, diff --git a/Sources/Backend/MainRequests/FetchProfileRequest.swift b/Sources/Backend/MainRequests/FetchProfileRequest.swift index 50fe66670..8fe0df87f 100644 --- a/Sources/Backend/MainRequests/FetchProfileRequest.swift +++ b/Sources/Backend/MainRequests/FetchProfileRequest.swift @@ -24,14 +24,14 @@ private struct FetchProfileRequest: HTTPRequestWithDecodableResponse { ) } - init(profileId: String, responseHash: String?) { + init(userId: AdaptyUserId, responseHash: String?) { endpoint = HTTPEndpoint( method: .get, - path: "/sdk/analytics/profiles/\(profileId)/" + path: "/sdk/analytics/profiles/\(userId.profileId)/" ) headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setBackendResponseHash(responseHash) } } @@ -61,11 +61,11 @@ extension HTTPRequestWithDecodableResponse where ResponseBody == AdaptyProfile? extension Backend.MainExecutor { func fetchProfile( - profileId: String, + userId: AdaptyUserId, responseHash: String? - ) async throws(HTTPError) -> VH { + ) async throws(HTTPError) -> VH? { let request = FetchProfileRequest( - profileId: profileId, + userId: userId, responseHash: responseHash ) @@ -74,6 +74,7 @@ extension Backend.MainExecutor { requestName: .fetchProfile ) - return VH(response.body, hash: response.headers.getBackendResponseHash()) + guard let profile = response.body else { return nil } + return VH(profile, hash: response.headers.getBackendResponseHash()) } } diff --git a/Sources/Backend/MainRequests/SetASATokenRequest.swift b/Sources/Backend/MainRequests/SetASATokenRequest.swift index 4282dff33..c4c4740c6 100644 --- a/Sources/Backend/MainRequests/SetASATokenRequest.swift +++ b/Sources/Backend/MainRequests/SetASATokenRequest.swift @@ -29,9 +29,9 @@ private struct SetASATokenRequest: HTTPEncodableRequest, HTTPRequestWithDecodabl ) } - init(profileId: String, token: String, responseHash: String?) { + init(userId: AdaptyUserId, token: String, responseHash: String?) { headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setBackendResponseHash(responseHash) self.token = token @@ -52,12 +52,12 @@ private struct SetASATokenRequest: HTTPEncodableRequest, HTTPRequestWithDecodabl extension Backend.MainExecutor { func sendASAToken( - profileId: String, + userId: AdaptyUserId, token: String, responseHash: String? - ) async throws(HTTPError) -> VH { + ) async throws(HTTPError) -> VH? { let request = SetASATokenRequest( - profileId: profileId, + userId: userId, token: token, responseHash: responseHash ) @@ -66,7 +66,7 @@ extension Backend.MainExecutor { requestName: .sendASAToken, logParams: ["token": token] ) - - return VH(response.body, hash: response.headers.getBackendResponseHash()) + guard let profile = response.body else { return nil } + return VH(profile, hash: response.headers.getBackendResponseHash()) } } diff --git a/Sources/Backend/MainRequests/SetAttributionDataRequest.swift b/Sources/Backend/MainRequests/SetAttributionDataRequest.swift index 78d682bd6..82f6f45fb 100644 --- a/Sources/Backend/MainRequests/SetAttributionDataRequest.swift +++ b/Sources/Backend/MainRequests/SetAttributionDataRequest.swift @@ -19,7 +19,7 @@ private struct SetAttributionDataRequest: HTTPEncodableRequest, HTTPRequestWithD let source: String let attributionJson: String - let profileId: String + let userId: AdaptyUserId func decodeDataResponse( _ response: HTTPDataResponse, @@ -32,14 +32,14 @@ private struct SetAttributionDataRequest: HTTPEncodableRequest, HTTPRequestWithD ) } - init(profileId: String, source: String, attributionJson: String, responseHash: String?) { + init(userId: AdaptyUserId, source: String, attributionJson: String, responseHash: String?) { headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setBackendResponseHash(responseHash) self.source = source self.attributionJson = attributionJson - self.profileId = profileId + self.userId = userId } enum CodingKeys: String, CodingKey { @@ -52,19 +52,19 @@ private struct SetAttributionDataRequest: HTTPEncodableRequest, HTTPRequestWithD var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(source, forKey: .source) try container.encode(attributionJson, forKey: .attributionJson) - try container.encode(profileId, forKey: .profileId) + try container.encode(userId.profileId, forKey: .profileId) } } extension Backend.MainExecutor { func setAttributionData( - profileId: String, + userId: AdaptyUserId, source: String, attributionJson: String, responseHash: String? - ) async throws(HTTPError) -> VH { + ) async throws(HTTPError) -> VH? { let request = SetAttributionDataRequest( - profileId: profileId, + userId: userId, source: source, attributionJson: attributionJson, responseHash: responseHash @@ -73,10 +73,11 @@ extension Backend.MainExecutor { request, requestName: .setAttributionData, logParams: [ - "source": source, + "source": source ] ) - return VH(response.body, hash: response.headers.getBackendResponseHash()) + guard let profile = response.body else { return nil } + return VH(profile, hash: response.headers.getBackendResponseHash()) } } diff --git a/Sources/Backend/MainRequests/SetIntegrationIdentifierRequest.swift b/Sources/Backend/MainRequests/SetIntegrationIdentifierRequest.swift index af6b46575..e42daf6c2 100644 --- a/Sources/Backend/MainRequests/SetIntegrationIdentifierRequest.swift +++ b/Sources/Backend/MainRequests/SetIntegrationIdentifierRequest.swift @@ -19,32 +19,32 @@ private struct SetIntegrationIdentifierRequest: HTTPEncodableRequest { let stamp = Log.stamp - let profileId: String + let userId: AdaptyUserId let keyValues: [String: String] - init(profileId: String, keyValues: [String: String]) { + init(userId: AdaptyUserId, keyValues: [String: String]) { headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) - self.profileId = profileId + self.userId = userId self.keyValues = keyValues } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: Backend.CodingKeys.self) var keyValues = keyValues - keyValues["profile_id"] = profileId + keyValues["profile_id"] = userId.profileId try container.encode(keyValues, forKey: .data) } } extension Backend.MainExecutor { func setIntegrationIdentifier( - profileId: String, + userId: AdaptyUserId, keyValues: [String: String] ) async throws(HTTPError) { let request = SetIntegrationIdentifierRequest( - profileId: profileId, + userId: userId, keyValues: keyValues ) let _: HTTPEmptyResponse = try await perform( diff --git a/Sources/Backend/MainRequests/SignSubscriptionOfferRequest.swift b/Sources/Backend/MainRequests/SignSubscriptionOfferRequest.swift index 7158af85c..88861579e 100644 --- a/Sources/Backend/MainRequests/SignSubscriptionOfferRequest.swift +++ b/Sources/Backend/MainRequests/SignSubscriptionOfferRequest.swift @@ -18,11 +18,12 @@ private struct SignSubscriptionOfferRequest: HTTPRequestWithDecodableResponse { let queryItems: QueryItems let stamp = Log.stamp - init(vendorProductId: String, offerId: String, profileId: String) { - headers = HTTPHeaders().setBackendProfileId(profileId) + init(vendorProductId: String, offerId: String, userId: AdaptyUserId) { + headers = HTTPHeaders() + .setUserProfileId(userId) queryItems = QueryItems() - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setVendorProductId(vendorProductId) .setOfferId(offerId) } @@ -30,21 +31,21 @@ private struct SignSubscriptionOfferRequest: HTTPRequestWithDecodableResponse { extension Backend.MainExecutor { func signSubscriptionOffer( - profileId: String, + userId: AdaptyUserId, vendorProductId: String, offerId: String ) async throws(HTTPError) -> AdaptySubscriptionOffer.Signature { let request = SignSubscriptionOfferRequest( vendorProductId: vendorProductId, offerId: offerId, - profileId: profileId + userId: userId ) let response = try await perform( request, requestName: .signSubscriptionOffer, logParams: [ "product_id": vendorProductId, - "discount_id": offerId, + "discount_id": offerId ] ) diff --git a/Sources/Backend/MainRequests/UpdateProfileRequest.swift b/Sources/Backend/MainRequests/UpdateProfileRequest.swift index 7974546f9..5dca4fc0b 100644 --- a/Sources/Backend/MainRequests/UpdateProfileRequest.swift +++ b/Sources/Backend/MainRequests/UpdateProfileRequest.swift @@ -13,7 +13,7 @@ private struct UpdateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda let headers: HTTPHeaders let stamp = Log.stamp - let profileId: String + let userId: AdaptyUserId let parameters: AdaptyProfileParameters? let environmentMeta: Environment.Meta? @@ -29,21 +29,21 @@ private struct UpdateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda } init( - profileId: String, + userId: AdaptyUserId, parameters: AdaptyProfileParameters?, environmentMeta: Environment.Meta?, responseHash: String? ) { endpoint = HTTPEndpoint( method: .patch, - path: "/sdk/analytics/profiles/\(profileId)/" + path: "/sdk/analytics/profiles/\(userId.profileId)/" ) headers = HTTPHeaders() - .setBackendProfileId(profileId) + .setUserProfileId(userId) .setBackendResponseHash(responseHash) - self.profileId = profileId + self.userId = userId self.parameters = parameters self.environmentMeta = environmentMeta } @@ -59,7 +59,7 @@ private struct UpdateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda var container = encoder.container(keyedBy: Backend.CodingKeys.self) var dataObject = container.nestedContainer(keyedBy: Backend.CodingKeys.self, forKey: .data) try dataObject.encode("adapty_analytics_profile", forKey: .type) - try dataObject.encode(profileId, forKey: .id) + try dataObject.encode(userId.profileId, forKey: .id) if let parameters { try dataObject.encode(parameters, forKey: .attributes) @@ -82,35 +82,14 @@ private struct UpdateProfileRequest: HTTPEncodableRequest, HTTPRequestWithDecoda } extension Backend.MainExecutor { - func syncProfile( - profileId: String, + func updateProfile( + userId: AdaptyUserId, parameters: AdaptyProfileParameters?, environmentMeta: Environment.Meta?, responseHash: String? - ) async throws(HTTPError) -> VH { - if parameters == nil, environmentMeta == nil { - try await fetchProfile( - profileId: profileId, - responseHash: responseHash - ) - } else { - try await updateProfile( - profileId: profileId, - parameters: parameters, - environmentMeta: environmentMeta, - responseHash: responseHash - ) - } - } - - private func updateProfile( - profileId: String, - parameters: AdaptyProfileParameters?, - environmentMeta: Environment.Meta?, - responseHash: String? - ) async throws(HTTPError) -> VH { + ) async throws(HTTPError) -> VH? { let request = UpdateProfileRequest( - profileId: profileId, + userId: userId, parameters: parameters, environmentMeta: environmentMeta, responseHash: responseHash @@ -121,6 +100,7 @@ extension Backend.MainExecutor { requestName: .updateProfile ) - return VH(response.body, hash: response.headers.getBackendResponseHash()) + guard let profile = response.body else { return nil } + return VH(profile, hash: response.headers.getBackendResponseHash()) } } diff --git a/Sources/Backend/MainRequests/ValidateReceiptRequest.swift b/Sources/Backend/MainRequests/ValidateReceiptRequest.swift index 80d926289..c30cce48e 100644 --- a/Sources/Backend/MainRequests/ValidateReceiptRequest.swift +++ b/Sources/Backend/MainRequests/ValidateReceiptRequest.swift @@ -17,12 +17,14 @@ private struct ValidateReceiptRequest: HTTPEncodableRequest, HTTPRequestWithDeco let headers: HTTPHeaders let stamp = Log.stamp - let profileId: String + let userId: AdaptyUserId let receipt: Data - init(profileId: String, receipt: Data) { - headers = HTTPHeaders().setBackendProfileId(profileId) - self.profileId = profileId + init(userId: AdaptyUserId, receipt: Data) { + headers = HTTPHeaders() + .setUserProfileId(userId) + + self.userId = userId self.receipt = receipt } @@ -37,17 +39,20 @@ private struct ValidateReceiptRequest: HTTPEncodableRequest, HTTPRequestWithDeco try dataObject.encode("adapty_inapps_apple_receipt_validation_result", forKey: .type) var attributesObject = dataObject.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) - try attributesObject.encode(profileId, forKey: .profileId) + try attributesObject.encode(userId.profileId, forKey: .profileId) try attributesObject.encode(receipt, forKey: .receipt) } } extension Backend.MainExecutor { func validateReceipt( - profileId: String, + userId: AdaptyUserId, receipt: Data ) async throws(HTTPError) -> VH { - let request = ValidateReceiptRequest(profileId: profileId, receipt: receipt) + let request = ValidateReceiptRequest( + userId: userId, + receipt: receipt + ) let response = try await perform( request, diff --git a/Sources/Backend/MainRequests/ValidateTransactionRequest.swift b/Sources/Backend/MainRequests/ValidateTransactionRequest.swift index 5c20a5ea4..756ac329a 100644 --- a/Sources/Backend/MainRequests/ValidateTransactionRequest.swift +++ b/Sources/Backend/MainRequests/ValidateTransactionRequest.swift @@ -18,12 +18,14 @@ private struct ValidateTransactionRequest: HTTPEncodableRequest, HTTPRequestWith let headers: HTTPHeaders let stamp = Log.stamp - let profileId: String + let userId: AdaptyUserId let requestSource: RequestSource - init(profileId: String, requestSource: RequestSource) { - headers = HTTPHeaders().setBackendProfileId(profileId) - self.profileId = profileId + init(userId: AdaptyUserId, requestSource: RequestSource) { + headers = HTTPHeaders() + .setUserProfileId(userId) + + self.userId = userId self.requestSource = requestSource } @@ -31,39 +33,75 @@ private struct ValidateTransactionRequest: HTTPEncodableRequest, HTTPRequestWith case profileId = "profile_id" case originalTransactionId = "original_transaction_id" case transactionId = "transaction_id" - case variationId = "variation_id" + case paywallVariationId = "variation_id" case requestSource = "request_source" + + case vendorProductId = "vendor_product_id" + case persistentPaywallVariationId = "variation_id_persistent" + case persistentOnboardingVariationId = "onboarding_variation_id" + case originalPrice = "original_price" + case discountPrice = "discount_price" + case priceLocale = "price_locale" + case storeCountry = "store_country" + case promotionalOfferId = "promotional_offer_id" + case subscriptionOffer = "offer" + case environment + } + + enum SubscriptionOfferKeys: String, CodingKey { + case periodUnit = "period_unit" + case periodNumberOfUnits = "number_of_units" + case paymentMode = "type" + case offerType = "category" } func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: Backend.CodingKeys.self) - var dataObject = container.nestedContainer(keyedBy: Backend.CodingKeys.self, forKey: .data) - try dataObject.encode("adapty_purchase_app_store_original_transaction_id_validation_result", forKey: .type) + var perentContainer = encoder.container(keyedBy: Backend.CodingKeys.self) + var dataContainer = perentContainer.nestedContainer(keyedBy: Backend.CodingKeys.self, forKey: .data) + try dataContainer.encode("adapty_purchase_app_store_original_transaction_id_validation_result", forKey: .type) + var container = dataContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) + switch requestSource { case let .restore(originalTransactionId): - var attributesObject = dataObject.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) - try attributesObject.encode(profileId, forKey: .profileId) - try attributesObject.encode(Adapty.ValidatePurchaseReason.restoreRawString, forKey: .requestSource) - try attributesObject.encode(originalTransactionId, forKey: .originalTransactionId) + try container.encode(userId.profileId, forKey: .profileId) + try container.encode(Adapty.ValidatePurchaseReason.restoreRawString, forKey: .requestSource) + try container.encode(originalTransactionId, forKey: .originalTransactionId) case let .report(transactionId, variationId): - var attributesObject = dataObject.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) - try attributesObject.encode(profileId, forKey: .profileId) - try attributesObject.encode(Adapty.ValidatePurchaseReason.reportRawString, forKey: .requestSource) - try attributesObject.encode(transactionId, forKey: .originalTransactionId) - try attributesObject.encode(transactionId, forKey: .transactionId) - try attributesObject.encodeIfPresent(variationId, forKey: .variationId) - case let .other(purchasedTransaction, reason): - try dataObject.encode(purchasedTransaction, forKey: .attributes) - var attributesObject = dataObject.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) - try attributesObject.encode(profileId, forKey: .profileId) - try attributesObject.encode(reason.rawString, forKey: .requestSource) + try container.encode(userId.profileId, forKey: .profileId) + try container.encode(Adapty.ValidatePurchaseReason.reportRawString, forKey: .requestSource) + try container.encode(transactionId, forKey: .originalTransactionId) + try container.encode(transactionId, forKey: .transactionId) + try container.encodeIfPresent(variationId, forKey: .paywallVariationId) + case let .other(info, payload, reason): + try container.encode(userId.profileId, forKey: .profileId) + try container.encode(reason.rawString, forKey: .requestSource) + try container.encode(info.transactionId, forKey: .transactionId) + try container.encode(info.originalTransactionId, forKey: .originalTransactionId) + try container.encode(info.vendorProductId, forKey: .vendorProductId) + try container.encodeIfPresent(info.price, forKey: .originalPrice) + try container.encodeIfPresent(info.subscriptionOffer?.price, forKey: .discountPrice) + try container.encodeIfPresent(info.priceLocale, forKey: .priceLocale) + try container.encodeIfPresent(info.storeCountry, forKey: .storeCountry) + try container.encodeIfPresent(info.subscriptionOffer?.id, forKey: .promotionalOfferId) + if let offer = info.subscriptionOffer { + var offerContainer = container.nestedContainer(keyedBy: SubscriptionOfferKeys.self, forKey: .subscriptionOffer) + try offerContainer.encode(offer.paymentMode, forKey: .paymentMode) + try offerContainer.encodeIfPresent(offer.period?.unit, forKey: .periodUnit) + try offerContainer.encodeIfPresent(offer.period?.numberOfUnits, forKey: .periodNumberOfUnits) + try offerContainer.encode(offer.offerType.rawValue, forKey: .offerType) + } + try container.encode(info.environment, forKey: .environment) + + try container.encodeIfPresent(payload.paywallVariationId, forKey: .paywallVariationId) + try container.encodeIfPresent(payload.persistentPaywallVariationId, forKey: .persistentPaywallVariationId) + try container.encodeIfPresent(payload.persistentOnboardingVariationId, forKey: .persistentOnboardingVariationId) } } enum RequestSource: Sendable { case restore(originalTransactionId: String) case report(transactionId: String, variationId: String?) - case other(PurchasedTransaction, reason: Adapty.ValidatePurchaseReason) + case other(PurchasedTransactionInfo, PurchasePayload, reason: Adapty.ValidatePurchaseReason) } } @@ -76,18 +114,18 @@ private extension Adapty.ValidatePurchaseReason { case .setVariation: "set_variation" case .observing: "observing" case .purchasing: "purchasing" - case .sk2Updates: "sk2_updates" + case .unfinished: "unfinished" } } } extension Backend.MainExecutor { - func syncTransaction( - profileId: String, - originalTransactionId: String + func syncTransactionsHistory( + originalTransactionId: String, + for userId: AdaptyUserId ) async throws(HTTPError) -> VH { let request = ValidateTransactionRequest( - profileId: profileId, + userId: userId, requestSource: .restore(originalTransactionId: originalTransactionId) ) let logParams: EventParameters = [ @@ -103,13 +141,13 @@ extension Backend.MainExecutor { return VH(response.body.value, hash: response.headers.getBackendResponseHash()) } - func reportTransaction( - profileId: String, - transactionId: String, - variationId: String? + func sendTransactionId( + _ transactionId: String, + with variationId: String?, + for userId: AdaptyUserId ) async throws(HTTPError) -> VH { let request = ValidateTransactionRequest( - profileId: profileId, + userId: userId, requestSource: .report(transactionId: transactionId, variationId: variationId) ) @@ -129,23 +167,23 @@ extension Backend.MainExecutor { } func validateTransaction( - profileId: String, - purchasedTransaction: PurchasedTransaction, + transactionInfo: PurchasedTransactionInfo, + payload: PurchasePayload, reason: Adapty.ValidatePurchaseReason ) async throws(HTTPError) -> VH { let request = ValidateTransactionRequest( - profileId: profileId, - requestSource: .other(purchasedTransaction, reason: reason) + userId: payload.userId, + requestSource: .other(transactionInfo, payload, reason: reason) ) let logParams: EventParameters = [ - "product_id": purchasedTransaction.vendorProductId, - "original_transaction_id": purchasedTransaction.originalTransactionId, - "transaction_id": purchasedTransaction.transactionId, - "variation_id": purchasedTransaction.paywallVariationId, - "variation_id_persistent": purchasedTransaction.persistentPaywallVariationId, - "onboarding_variation_id": purchasedTransaction.persistentOnboardingVariationId, - "promotional_offer_id": purchasedTransaction.subscriptionOffer?.id, - "environment": purchasedTransaction.environment, + "product_id": transactionInfo.vendorProductId, + "original_transaction_id": transactionInfo.originalTransactionId, + "transaction_id": transactionInfo.transactionId, + "variation_id": payload.paywallVariationId, + "variation_id_persistent": payload.persistentPaywallVariationId, + "onboarding_variation_id": payload.persistentOnboardingVariationId, + "promotional_offer_id": transactionInfo.subscriptionOffer?.id, + "environment": transactionInfo.environment, "request_source": reason.rawString, ] let response = try await perform( diff --git a/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementRequest.swift b/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementRequest.swift index c2bf05888..914fab2e3 100644 --- a/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementRequest.swift +++ b/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementRequest.swift @@ -34,7 +34,7 @@ extension Backend.FallbackExecutor { @inlinable func fetchFallbackPlacement( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, paywallVariationId: String, locale: AdaptyLocale, @@ -44,7 +44,7 @@ extension Backend.FallbackExecutor { ) async throws(HTTPError) -> AdaptyPlacementChosen { try await _fetchFallbackPlacement( apiKeyPrefix, - profileId, + userId, placementId, paywallVariationId, locale: locale, @@ -57,7 +57,7 @@ extension Backend.FallbackExecutor { private func _fetchFallbackPlacement( _ apiKeyPrefix: String, - _ profileId: String, + _ userId: AdaptyUserId, _ placementId: String, _ paywallVariationId: String, locale: AdaptyLocale, @@ -111,7 +111,7 @@ extension Backend.FallbackExecutor { try await AdaptyPlacementChosen.decodePlacementResponse( response, withConfiguration: configuration, - withProfileId: profileId, + withUserId: userId, withRequestLocale: requestLocale, withCached: cached ) @@ -127,7 +127,7 @@ extension Backend.FallbackExecutor { return try await _fetchFallbackPlacement( apiKeyPrefix, - profileId, + userId, placementId, paywallVariationId, locale: .defaultPlacementLocale, diff --git a/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementVariationsRequest.swift b/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementVariationsRequest.swift index 6f1d099ec..e4ae77973 100644 --- a/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementVariationsRequest.swift +++ b/Sources/Backend/RemoteFilesRequests/FetchFallbackPlacementVariationsRequest.swift @@ -35,7 +35,7 @@ private extension BackendExecutor { func performFetchFallbackPlacementVariationsRequest( requestName: APIRequestName, apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, locale: AdaptyLocale, cached: Content?, @@ -47,7 +47,7 @@ private extension BackendExecutor { try await _performFetchFallbackPlacementVariationsRequest( requestName, apiKeyPrefix, - profileId, + userId, placementId, locale: locale, locale, @@ -62,7 +62,7 @@ private extension BackendExecutor { private func _performFetchFallbackPlacementVariationsRequest( _ requestName: APIRequestName, _ apiKeyPrefix: String, - _ profileId: String, + _ userId: AdaptyUserId, _ placementId: String, locale: AdaptyLocale, _ requestLocale: AdaptyLocale, @@ -112,7 +112,7 @@ private extension BackendExecutor { try await AdaptyPlacementChosen.decodePlacementVariationsResponse( response, withConfiguration: configuration, - withProfileId: profileId, + withUserId: userId, withPlacementId: placementId, withRequestLocale: requestLocale, withCached: cached, @@ -132,7 +132,7 @@ private extension BackendExecutor { return try await _performFetchFallbackPlacementVariationsRequest( requestName, apiKeyPrefix, - profileId, + userId, placementId, locale: .defaultPlacementLocale, requestLocale, @@ -149,7 +149,7 @@ private extension BackendExecutor { extension Backend.FallbackExecutor { func fetchFallbackPlacementVariations( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, locale: AdaptyLocale, cached: Content?, @@ -167,7 +167,7 @@ extension Backend.FallbackExecutor { return try await performFetchFallbackPlacementVariationsRequest( requestName: requestName, apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, cached: cached, @@ -182,7 +182,7 @@ extension Backend.FallbackExecutor { extension Backend.ConfigsExecutor { func fetchPlacementVariationsForDefaultAudience( apiKeyPrefix: String, - profileId: String, + userId: AdaptyUserId, placementId: String, locale: AdaptyLocale, cached: Content?, @@ -200,7 +200,7 @@ extension Backend.ConfigsExecutor { return try await performFetchFallbackPlacementVariationsRequest( requestName: requestName, apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, cached: cached, diff --git a/Sources/Backend/UARequests/RegisterInstallRequest.swift b/Sources/Backend/UARequests/RegisterInstallRequest.swift index e0bc5c563..a0bb3c8e7 100644 --- a/Sources/Backend/UARequests/RegisterInstallRequest.swift +++ b/Sources/Backend/UARequests/RegisterInstallRequest.swift @@ -20,8 +20,10 @@ private struct RegisterInstallRequest: HTTPEncodableRequest, HTTPRequestWithDeco let installInfo: Environment.InstallInfo - init(profileId: String, installInfo: Environment.InstallInfo) { - headers = HTTPHeaders().setBackendProfileId(profileId) + init(userId: AdaptyUserId, installInfo: Environment.InstallInfo) { + headers = HTTPHeaders() + .setUserProfileId(userId) + self.installInfo = installInfo } @@ -41,12 +43,12 @@ extension Backend.UAExecutor { } func registerInstall( - profileId: String, + userId: AdaptyUserId, installInfo: Environment.InstallInfo, maxRetries: Int = 5 ) async throws(HTTPError) -> RegistrationInstallResponse? { let request = RegisterInstallRequest( - profileId: profileId, + userId: userId, installInfo: installInfo ) var attempt = 0 diff --git a/Sources/Configuration/AdaptyConfiguration.Builder+Decodable.swift b/Sources/Configuration/AdaptyConfiguration.Builder+Decodable.swift index f245c14bb..919cbf903 100644 --- a/Sources/Configuration/AdaptyConfiguration.Builder+Decodable.swift +++ b/Sources/Configuration/AdaptyConfiguration.Builder+Decodable.swift @@ -11,7 +11,7 @@ extension AdaptyConfiguration.Builder: Decodable { private enum CodingKeys: String, CodingKey { case apiKey = "api_key" case customerUserId = "customer_user_id" - case appAccountToken = "app_account_token" + case customerIdentityParameters = "customer_identity_parameters" case observerMode = "observer_mode" case idfaCollectionDisabled = "apple_idfa_collection_disabled" case ipAddressCollectionDisabled = "ip_address_collection_disabled" @@ -23,6 +23,7 @@ extension AdaptyConfiguration.Builder: Decodable { case backendProxyHost = "backend_proxy_host" case backendProxyPort = "backend_proxy_port" + case transactionFinishBehavior = "transaction_finish_behavior" case logLevel = "log_level" case crossPlatformSDKName = "cross_platform_sdk_name" @@ -50,10 +51,12 @@ extension AdaptyConfiguration.Builder: Decodable { nil } + let customerIdentityParameters = try container.decodeIfPresent(CustomerIdentityParameters.self, forKey: .customerIdentityParameters) + try self.init( apiKey: container.decode(String.self, forKey: .apiKey), customerUserId: container.decodeIfPresent(String.self, forKey: .customerUserId), - appAccountToken: container.decodeIfPresent(UUID.self, forKey: .appAccountToken), + appAccountToken: customerIdentityParameters?.appAccountToken, observerMode: container.decodeIfPresent(Bool.self, forKey: .observerMode), idfaCollectionDisabled: container.decodeIfPresent(Bool.self, forKey: .idfaCollectionDisabled), ipAddressCollectionDisabled: container.decodeIfPresent(Bool.self, forKey: .ipAddressCollectionDisabled), @@ -64,6 +67,7 @@ extension AdaptyConfiguration.Builder: Decodable { backendConfigsBaseUrl: container.decodeIfPresent(URL.self, forKey: .backendConfigsBaseUrl), backendUABaseUrl: container.decodeIfPresent(URL.self, forKey: .backendUABaseUrl), backendProxy: proxy, + transactionFinishBehavior: nil, logLevel: container.decodeIfPresent(AdaptyLog.Level.self, forKey: .logLevel), crossPlatformSDK: crossPlatformSDK ) diff --git a/Sources/Configuration/AdaptyConfiguration.Builder.swift b/Sources/Configuration/AdaptyConfiguration.Builder.swift index 670599029..2ec4b81ba 100644 --- a/Sources/Configuration/AdaptyConfiguration.Builder.swift +++ b/Sources/Configuration/AdaptyConfiguration.Builder.swift @@ -25,8 +25,8 @@ extension AdaptyConfiguration { } self.init( - apiKey: apiKey, - customerUserId: builder.customerUserId, + apiKey: apiKey.trimmed, + customerUserId: builder.customerUserId.trimmed.nonEmptyOrNil, appAccountToken: builder.appAccountToken, observerMode: builder.observerMode ?? defaultValue.observerMode, idfaCollectionDisabled: builder.idfaCollectionDisabled ?? defaultValue.idfaCollectionDisabled, @@ -40,13 +40,16 @@ extension AdaptyConfiguration { proxy: builder.backendProxy ?? defaultBackend.proxy ), logLevel: builder.logLevel, - crossPlatformSDK: builder.crossPlatformSDK + crossPlatformSDK: builder.crossPlatformSDK.map { + (name: $0.name.trimmed, version: $0.version.trimmed) + }, + transactionFinishBehavior: builder.transactionFinishBehavior ?? defaultValue.transactionFinishBehavior ) } public static func builder(withAPIKey apiKey: String) -> AdaptyConfiguration.Builder { .init( - apiKey: apiKey, + apiKey: apiKey.trimmed, customerUserId: nil, appAccountToken: nil, observerMode: nil, @@ -59,6 +62,7 @@ extension AdaptyConfiguration { backendConfigsBaseUrl: nil, backendUABaseUrl: nil, backendProxy: nil, + transactionFinishBehavior: nil, logLevel: nil, crossPlatformSDK: nil ) @@ -82,6 +86,8 @@ public extension AdaptyConfiguration { public private(set) var backendUABaseUrl: URL? public private(set) var backendProxy: (host: String, port: Int)? + public private(set) var transactionFinishBehavior: TransactionFinishBehavior? + public private(set) var logLevel: AdaptyLog.Level? package private(set) var crossPlatformSDK: (name: String, version: String)? @@ -100,6 +106,7 @@ public extension AdaptyConfiguration { backendConfigsBaseUrl: URL?, backendUABaseUrl: URL?, backendProxy: (host: String, port: Int)?, + transactionFinishBehavior: TransactionFinishBehavior?, logLevel: AdaptyLog.Level?, crossPlatformSDK: (name: String, version: String)? ) { @@ -116,6 +123,7 @@ public extension AdaptyConfiguration { self.backendConfigsBaseUrl = backendConfigsBaseUrl self.backendUABaseUrl = backendUABaseUrl self.backendProxy = backendProxy + self.transactionFinishBehavior = transactionFinishBehavior self.logLevel = logLevel self.crossPlatformSDK = crossPlatformSDK } @@ -203,7 +211,13 @@ public extension AdaptyConfiguration.Builder { @discardableResult func with(proxy host: String, port: Int) -> Self { - backendProxy = (host: host, port: port) + backendProxy = (host: host.trimmed, port: port) + return self + } + + @discardableResult + func with(transactionFinishBehavior value: AdaptyConfiguration.TransactionFinishBehavior) -> Self { + transactionFinishBehavior = value return self } diff --git a/Sources/Configuration/AdaptyConfiguration.TransactionFinishBehavior.swift b/Sources/Configuration/AdaptyConfiguration.TransactionFinishBehavior.swift new file mode 100644 index 000000000..501574764 --- /dev/null +++ b/Sources/Configuration/AdaptyConfiguration.TransactionFinishBehavior.swift @@ -0,0 +1,14 @@ +// +// AdaptyConfiguration.TransactionFinishBehavior.swift +// AdaptySD +// +// Created by Aleksei Valiano on 07.09.2025. +// + +public extension AdaptyConfiguration { + enum TransactionFinishBehavior: Sendable { + public static let `default` = TransactionFinishBehavior.auto + case auto + case manual + } +} diff --git a/Sources/Configuration/AdaptyConfiguration.swift b/Sources/Configuration/AdaptyConfiguration.swift index 80bfde640..46bb28fc6 100644 --- a/Sources/Configuration/AdaptyConfiguration.swift +++ b/Sources/Configuration/AdaptyConfiguration.swift @@ -12,7 +12,8 @@ public struct AdaptyConfiguration: Sendable { observerMode: false, idfaCollectionDisabled: false, ipAddressCollectionDisabled: false, - backend: Backend.URLs.defaultPublicEnvironment + backend: Backend.URLs.defaultPublicEnvironment, + transactionFinishBehavior: TransactionFinishBehavior.default ) let apiKey: String @@ -25,4 +26,10 @@ public struct AdaptyConfiguration: Sendable { let backend: Backend.URLs let logLevel: AdaptyLog.Level? let crossPlatformSDK: (name: String, version: String)? + let transactionFinishBehavior: TransactionFinishBehavior +} + +extension AdaptyConfiguration { + @AdaptyActor + static var transactionFinishBehavior = Self.default.transactionFinishBehavior } diff --git a/Sources/Envoriment/Environment.Device.idfa.swift b/Sources/Envoriment/Environment.Device.idfa.swift index 35355a083..6c0c1551e 100644 --- a/Sources/Envoriment/Environment.Device.idfa.swift +++ b/Sources/Envoriment/Environment.Device.idfa.swift @@ -91,7 +91,7 @@ extension Environment.Device { case .authorized: ASIdentifierManager.shared().advertisingIdentifier.uuidString default: - String?.none + nil } #else return ASIdentifierManager.shared().advertisingIdentifier.uuidString diff --git a/Sources/Envoriment/Environment.Device.idfv.swift b/Sources/Envoriment/Environment.Device.idfv.swift index 9d62578ae..9d1397ee3 100644 --- a/Sources/Envoriment/Environment.Device.idfv.swift +++ b/Sources/Envoriment/Environment.Device.idfv.swift @@ -30,7 +30,7 @@ extension Environment.Device { let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, matchingDict) defer { IOObjectRelease(platformExpert) } - guard platformExpert != 0 else { return String?.none } + guard platformExpert != 0 else { return nil } return IORegistryEntryCreateCFProperty( platformExpert, kIOPlatformUUIDKey as CFString, diff --git a/Sources/Envoriment/Environment.Device.swift b/Sources/Envoriment/Environment.Device.swift index 2fe37ef7f..bf017b42d 100644 --- a/Sources/Envoriment/Environment.Device.swift +++ b/Sources/Envoriment/Environment.Device.swift @@ -31,11 +31,7 @@ extension Environment { } IOObjectRelease(service) - if modelIdentifier?.isEmpty ?? false { - modelIdentifier = nil - } - - return modelIdentifier ?? "unknown device" + return modelIdentifier.nonEmptyOrNil ?? "unknown device" #else var systemInfo = utsname() diff --git a/Sources/Envoriment/Environment.Device.webUserAgent.swift b/Sources/Envoriment/Environment.Device.webUserAgent.swift index 964b057a8..388f2e6dd 100644 --- a/Sources/Envoriment/Environment.Device.webUserAgent.swift +++ b/Sources/Envoriment/Environment.Device.webUserAgent.swift @@ -23,11 +23,11 @@ extension Environment.Device { if let result = _userAgent { return result } - + #if canImport(WebKit) let result = await WKWebView().value(forKey: "userAgent").flatMap { $0 as? String } #else - let result = String?.none + let result = nil #endif _userAgent = .some(result) diff --git a/Sources/Errors/AdaptyError+Description.swift b/Sources/Errors/AdaptyError+Description.swift index 0c0609af9..91f64cf15 100644 --- a/Sources/Errors/AdaptyError+Description.swift +++ b/Sources/Errors/AdaptyError+Description.swift @@ -14,6 +14,7 @@ extension InternalAdaptyError: CustomDebugStringConvertible { case .activateOnceError: "Adapty can only be activated once. Ensure that the SDK activation call is not made more than once." case .unidentifiedUserLogout: "Logout cannot be called for an unidentified user" case .cantMakePayments: "In-App Purchases are not available on this device. Please check your device settings." + case .notAllowedInObserveMode: "The operation is not allowed in observe mode." case .notActivated: "Adapty SDK is not initialized. You need to activate the SDK before using its methods." case .profileWasChanged: "The user was replaced with a different user in the SDK during the execution of the operation." case let .fetchFailed(_, description, _): description @@ -54,6 +55,7 @@ extension StoreKitManagerError: CustomDebugStringConvertible { case .refreshReceiptFailed: "Failed to refresh the purchase receipt." case .productPurchaseFailed: "Failed to complete the product purchase. Refer to the original error for more details." case .requestSKProductsFailed: "Failed to fetch product information from the StoreKit. Refer to the original error for more details." + case .unknownTransactionId: "Transaction identifier is nil" case let .transactionUnverified(_, error): if let customError = error as? CustomAdaptyError { "Transaction verification failed: \(customError.debugDescription)" @@ -63,6 +65,7 @@ extension StoreKitManagerError: CustomDebugStringConvertible { case let .invalidOffer(_, error): error case .getSubscriptionInfoStatusFailed: "Failed to retrieve subscription information." + case .paymentPendingError: "The payment is deferred." } } } diff --git a/Sources/Errors/AdaptyError.swift b/Sources/Errors/AdaptyError.swift index 70772f8ca..9f3ad3f16 100644 --- a/Sources/Errors/AdaptyError.swift +++ b/Sources/Errors/AdaptyError.swift @@ -76,6 +76,8 @@ public extension AdaptyError { case productPurchaseFailed = 1006 case refreshReceiptFailed = 1010 case fetchSubscriptionStatusFailed = 1020 + case paymentPendingError = 1050 + case unknownTransactionId = 1030 /// Adapty SDK is not activated. case notActivated = 2002 diff --git a/Sources/Errors/CustomAdaptyError.swift b/Sources/Errors/CustomAdaptyError.swift index 29e38afb6..a2301fcda 100644 --- a/Sources/Errors/CustomAdaptyError.swift +++ b/Sources/Errors/CustomAdaptyError.swift @@ -95,21 +95,23 @@ extension StoreKitManagerError: CustomAdaptyError { var adaptyErrorCode: AdaptyError.ErrorCode { if let code = convertErrorCode(skError) { return code } - switch self { - case .interrupted: return .operationInterrupted - case .noProductIDsFound: return .noProductIDsFound - case .receiptIsEmpty: return .cantReadReceipt - case .refreshReceiptFailed: return .refreshReceiptFailed - case .requestSKProductsFailed: return .productRequestFailed - case .productPurchaseFailed: return .productPurchaseFailed + return switch self { + case .interrupted: .operationInterrupted + case .noProductIDsFound: .noProductIDsFound + case .receiptIsEmpty: .cantReadReceipt + case .refreshReceiptFailed: .refreshReceiptFailed + case .requestSKProductsFailed: .productRequestFailed + case .productPurchaseFailed: .productPurchaseFailed + case .unknownTransactionId: .unknownTransactionId case let .transactionUnverified(_, error): if let customError = error as? CustomAdaptyError { - return customError.adaptyErrorCode + customError.adaptyErrorCode } else { - return .networkFailed + .networkFailed } - case .invalidOffer: return .invalidOfferIdentifier - case .getSubscriptionInfoStatusFailed: return .fetchSubscriptionStatusFailed + case .invalidOffer: .invalidOfferIdentifier + case .getSubscriptionInfoStatusFailed: .fetchSubscriptionStatusFailed + case .paymentPendingError: .paymentPendingError } } diff --git a/Sources/Errors/InternalAdaptyError.swift b/Sources/Errors/InternalAdaptyError.swift index 4e21a10ac..712d01276 100644 --- a/Sources/Errors/InternalAdaptyError.swift +++ b/Sources/Errors/InternalAdaptyError.swift @@ -12,6 +12,8 @@ enum InternalAdaptyError: Error { case unknown(AdaptyError.Source, String, error: Error) case activateOnceError(AdaptyError.Source) case cantMakePayments(AdaptyError.Source) + case notAllowedInObserveMode(AdaptyError.Source) + case notActivated(AdaptyError.Source) case unidentifiedUserLogout(AdaptyError.Source) @@ -33,6 +35,8 @@ extension InternalAdaptyError: CustomStringConvertible { "AdaptyError.unidentifiedUserLogout(\(source))" case let .cantMakePayments(source): "AdaptyError.cantMakePayments(\(source))" + case let .notAllowedInObserveMode(source): + "AdaptyError.notAllowedInObserveMode(\(source))" case let .notActivated(source): "AdaptyError.notActivated(\(source))" case let .profileWasChanged(source): @@ -54,6 +58,7 @@ extension InternalAdaptyError { let .activateOnceError(src), let .unidentifiedUserLogout(src), let .cantMakePayments(src), + let .notAllowedInObserveMode(src), let .notActivated(src), let .profileWasChanged(src), let .fetchFailed(src, _, _), @@ -84,6 +89,7 @@ extension InternalAdaptyError: CustomNSError { case .activateOnceError: .activateOnceError case .unidentifiedUserLogout: .unidentifiedUserLogout case .cantMakePayments: .cantMakePayments + case .notAllowedInObserveMode: .cantMakePayments case .notActivated: .notActivated case .profileWasChanged: .profileWasChanged case .fetchFailed: .networkFailed @@ -124,6 +130,10 @@ extension AdaptyError { InternalAdaptyError.cantMakePayments(AdaptyError.Source(file: file, function: function, line: line)).asAdaptyError } + static func notAllowedInObserveMode(file: String = #fileID, function: String = #function, line: UInt = #line) -> Self { + InternalAdaptyError.notAllowedInObserveMode(AdaptyError.Source(file: file, function: function, line: line)).asAdaptyError + } + static func notActivated(file: String = #fileID, function: String = #function, line: UInt = #line) -> Self { InternalAdaptyError.notActivated(AdaptyError.Source(file: file, function: function, line: line)).asAdaptyError } diff --git a/Sources/Events/Adapty+Events.swift b/Sources/Events/Adapty+Events.swift index 54f0def69..bf9765a5e 100644 --- a/Sources/Events/Adapty+Events.swift +++ b/Sources/Events/Adapty+Events.swift @@ -11,13 +11,13 @@ extension Adapty { @EventsManagerActor static let eventsManager = EventsManager() - static func trackEvent(_ event: Event, for profileId: String? = nil) { + static func trackEvent(_ event: Event, for userId: AdaptyUserId? = nil) { let now = Date() - let profileId = profileId ?? ProfileStorage.profileId + let userId = userId ?? ProfileStorage.userId Task.detached(priority: .utility) { let event = await Event.Unpacked( event: event, - profileId: profileId, + userId: userId, environment: Environment.instance, createdAt: now ) @@ -57,7 +57,7 @@ extension Adapty { return } - trackEvent(event, for: draw.profileId) + trackEvent(event, for: draw.userId) } package static func logShowPaywall(_ paywall: AdaptyPaywall, viewConfiguration: AdaptyViewConfiguration) { @@ -70,7 +70,7 @@ public extension Adapty { do { let event = await Event.Unpacked( event: event, - profileId: ProfileStorage.profileId, + userId: ProfileStorage.userId, environment: Environment.instance ) try await eventsManager.trackEvent(event) @@ -116,12 +116,6 @@ public extension Adapty { nonisolated static func logShowOnboarding(_ params: AdaptyOnboardingScreenParameters) async throws(AdaptyError) { try await withActivatedSDK(methodName: .logShowOnboarding) { _ throws(AdaptyError) in - guard params.screenOrder > 0 else { - let error = AdaptyError.wrongParamOnboardingScreenOrder() - Log.default.error(error.debugDescription) - throw error - } - try await _trackEvent(.legacyOnboardingScreenShowed(params)) } } diff --git a/Sources/Events/Entities/AdaptyOnboardingScreenParameters.swift b/Sources/Events/Entities/AdaptyOnboardingScreenParameters.swift index 5c129972f..3d2b2b042 100644 --- a/Sources/Events/Entities/AdaptyOnboardingScreenParameters.swift +++ b/Sources/Events/Entities/AdaptyOnboardingScreenParameters.swift @@ -11,6 +11,18 @@ public struct AdaptyOnboardingScreenParameters: Sendable { public let name: String? public let screenName: String? public let screenOrder: UInt + + public init(name: String? = nil, screenName: String? = nil, screenOrder: UInt = 0) throws(AdaptyError) { + guard screenOrder > 0 else { + let error = AdaptyError.wrongParamOnboardingScreenOrder() + Log.default.error(error.debugDescription) + throw error + } + + self.name = name.trimmed.nonEmptyOrNil + self.screenName = screenName.trimmed.nonEmptyOrNil + self.screenOrder = screenOrder + } } extension AdaptyOnboardingScreenParameters: Codable { diff --git a/Sources/Events/Entities/AdaptySystemEventParameters.swift b/Sources/Events/Entities/AdaptySystemEventParameters.swift index f7e6b933e..f2f6d3a76 100644 --- a/Sources/Events/Entities/AdaptySystemEventParameters.swift +++ b/Sources/Events/Entities/AdaptySystemEventParameters.swift @@ -25,6 +25,17 @@ private enum CodingKeys: String, CodingKey { typealias EventParameters = [String: (any Sendable & Encodable)?] +extension EventParameters { + var removeNil: EventParameters { + var result: EventParameters = [:] + for (key, value) in self { + guard let value = value else { continue } + result[key] = value + } + return result + } +} + private extension Encoder { func encode(_ params: EventParameters?) throws { guard let params else { return } @@ -66,7 +77,7 @@ enum APIRequestName: String { case fetchEventsConfig = "get_events_blacklist" - case fetchAllProductVendorIds = "get_products_ids" + case fetchAllProductInfo = "get_all_products_info" case reqisterInstall = "reqister_install" } @@ -141,7 +152,7 @@ private extension HTTPHeaders { var filtered: HTTPHeaders? { let filtered = filter { $0.key.lowercased().hasSuffix(Suffix.cacheStatus) } - return filtered.isEmpty ? nil : filtered + return filtered.nonEmptyOrNil } } @@ -157,6 +168,8 @@ enum MethodName: String { case setIntegrationIdentifiers = "set_integration_identifiers" case setVariationId = "set_variation_id" + case manualFinishTransaction = "manual_finish_transaction" + case reportSK1Transaction = "report_transaction_sk1" case reportSK2Transaction = "report_transaction_sk2" case getPaywallProducts = "get_paywall_products" @@ -166,6 +179,7 @@ enum MethodName: String { case makePurchase = "make_purchase" case openWebPaywall = "open_web_paywall" case createWebPaywallUrl = "create_web_paywall_url" + case getUnfinishedTransactions = "get_unfinished_transactions" case getCurrentInstallationStatus = "get_current_installation_status" case restorePurchases = "restore_purchases" @@ -241,8 +255,9 @@ enum AppleMethodName: String { case fetchSK1Products = "fetch_sk1_products" case fetchSK2Products = "fetch_sk2_products" - case getAllSK1Transactions = "get_all_sk1_transactions" case getAllSK2Transactions = "get_all_sk2_transactions" + case getSK2CurrentEntitlements = "get_sk2_current_entitlements" + case getUnfinishedSK2Transactions = "get_unfinished_sk2_transactions" case getReceipt = "get_receipt" case refreshReceipt = "refresh_receipt" diff --git a/Sources/Events/Entities/Event.Packed.swift b/Sources/Events/Entities/Event.Packed.swift index 12f84bdce..b49b12eb8 100644 --- a/Sources/Events/Entities/Event.Packed.swift +++ b/Sources/Events/Entities/Event.Packed.swift @@ -49,7 +49,7 @@ private extension Event { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: Event.CodingKeys.self) try container.encode(original.id, forKey: .id) - try container.encode(original.profileId, forKey: .profileId) + try container.encode(original.userId.profileId, forKey: .profileId) try container.encode(original.environment.sessionIdentifier, forKey: .sessionId) try container.encode(original.createdAt, forKey: .createdAt) try container.encode(counter, forKey: .counter) diff --git a/Sources/Events/Entities/Event.Unpacked.swift b/Sources/Events/Entities/Event.Unpacked.swift index 5ee1865e8..97d35ab0d 100644 --- a/Sources/Events/Entities/Event.Unpacked.swift +++ b/Sources/Events/Entities/Event.Unpacked.swift @@ -11,20 +11,20 @@ extension Event { struct Unpacked: Sendable { let id: String let event: Event - let profileId: String + let userId: AdaptyUserId let environment: Environment let createdAt: Date init( id: String = UUID().uuidString.lowercased(), event: Event, - profileId: String, + userId: AdaptyUserId, environment: Environment, createdAt: Date = Date() ) { self.id = id self.event = event - self.profileId = profileId + self.userId = userId self.environment = environment self.createdAt = createdAt } diff --git a/Sources/Events/EventsManager.swift b/Sources/Events/EventsManager.swift index 5d2ab2f70..79e64b69b 100644 --- a/Sources/Events/EventsManager.swift +++ b/Sources/Events/EventsManager.swift @@ -91,7 +91,7 @@ final class EventsManager { if configuration.isExpired { do { configuration = try await session.fetchEventsConfig( - profileId: ProfileStorage.profileId + userId: ProfileStorage.userId ) } catch .backend, .decoding { configuration = .init(blacklist: Event.defaultBlackList, expiration: Date() + 24 * 60 * 60) @@ -105,13 +105,13 @@ final class EventsManager { blackList: configuration.blacklist ) - guard !events.elements.isEmpty else { + guard events.elements.isNotEmpty else { eventStorages.subtract(oldIndexes: events.endIndex) return } try await session.sendEvents( - profileId: ProfileStorage.profileId, + userId: ProfileStorage.userId, events: events.elements ) @@ -125,7 +125,7 @@ final class EventsManager { @EventsManagerActor private extension [EventCollectionStorage] { - var hasEvents: Bool { contains { !$0.isEmpty } } + var hasEvents: Bool { contains { $0.isNotEmpty } } func getEvents(limit: Int, blackList: Set) -> (elements: [Data], endIndex: [Int?]) { var limit = limit diff --git a/Sources/Events/Storage/EventCollection.swift b/Sources/Events/Storage/EventCollection.swift index b8126102b..3cbf81a17 100644 --- a/Sources/Events/Storage/EventCollection.swift +++ b/Sources/Events/Storage/EventCollection.swift @@ -7,7 +7,7 @@ import Foundation -struct EventCollection: Sendable { +struct EventCollection: Sendable, Emptiable { private(set) var elements: [Element] private(set) var startIndex: Int var endIndex: Int { endIndex(elements.count) } @@ -16,7 +16,7 @@ struct EventCollection: Sendable { func endIndex(_ count: Int) -> Int { startIndex + count - 1 } mutating func removeAll() { - guard !elements.isEmpty else { return } + guard elements.isNotEmpty else { return } startIndex += elements.count elements = [] } diff --git a/Sources/Events/Storage/EventCollectionStorage.swift b/Sources/Events/Storage/EventCollectionStorage.swift index 65551f481..abaaacb21 100644 --- a/Sources/Events/Storage/EventCollectionStorage.swift +++ b/Sources/Events/Storage/EventCollectionStorage.swift @@ -18,7 +18,10 @@ final class EventCollectionStorage { private let storage: EventsStorage private var events: EventCollection + @inlinable var isEmpty: Bool { events.isEmpty } + @inlinable + var isNotEmpty: Bool { !isEmpty } init(with storage: EventsStorage) { self.storage = storage @@ -28,7 +31,7 @@ final class EventCollectionStorage { } func getEvents(limit: Int, blackList: Set) -> (elements: [Data], endIndex: Int)? { - guard limit > 0, !events.isEmpty else { return nil } + guard limit > 0, events.isNotEmpty else { return nil } var elements = [Data]() var count = 0 diff --git a/Sources/Events/Storage/EventsStorage.swift b/Sources/Events/Storage/EventsStorage.swift index 4169563dd..dda85923b 100644 --- a/Sources/Events/Storage/EventsStorage.swift +++ b/Sources/Events/Storage/EventsStorage.swift @@ -10,7 +10,7 @@ import Foundation private let log = Log.storage @EventsManagerActor -final class EventsStorage: Sendable { +final class EventsStorage { static var all = Kind.allCases.map { EventsStorage(kind: $0) } private let kind: Kind @@ -47,7 +47,7 @@ private enum Kind: Sendable, Hashable, CaseIterable { } @EventsManagerActor -private final class AllEventsStorage: Sendable { +private final class AllEventsStorage { private static let userDefaults = Storage.userDefaults static var eventsCount: [Kind: Int] = Dictionary(Kind.allCases.map { diff --git a/Sources/LifecycleManager.swift b/Sources/LifecycleManager.swift index 629cfacc9..d90abb410 100644 --- a/Sources/LifecycleManager.swift +++ b/Sources/LifecycleManager.swift @@ -60,12 +60,12 @@ final class LifecycleManager { let now = Date() - guard let lastOpenedWebPaywallAt = storage.lastOpenedWebPaywallDate else { + guard let lastOpenedWebPaywallAt = storage.lastOpenedWebPaywallDate() else { log.debug("LifecycleManager: \(stamp) calculateInterval: NO WEB PAYWALL") return defaultValue } - if let lastStartAcceleratedSyncAt = storage.lastStartAcceleratedSyncProfileDate, lastStartAcceleratedSyncAt > lastOpenedWebPaywallAt { + if let lastStartAcceleratedSyncAt = storage.lastStartAcceleratedSyncProfileDate(), lastStartAcceleratedSyncAt > lastOpenedWebPaywallAt { let timeLeft = now.timeIntervalSince(lastStartAcceleratedSyncAt) if timeLeft < profileUpdateAcceleratedDuration { diff --git a/Sources/Logging/Entities/AdaptyLog.Level+stringLiteral.swift b/Sources/Logging/Entities/AdaptyLog.Level+stringLiteral.swift index 83bd2dc00..db4ff5692 100644 --- a/Sources/Logging/Entities/AdaptyLog.Level+stringLiteral.swift +++ b/Sources/Logging/Entities/AdaptyLog.Level+stringLiteral.swift @@ -19,7 +19,7 @@ extension AdaptyLog.Level: ExpressibleByStringLiteral { public init(stringLiteral value: String) { self = switch CodingValues(rawValue: value.lowercased()) { - case .none: .default + case nil: .default case .error: .error case .warn: .warn case .info: .info diff --git a/Sources/Placements/Adapty+FallbackFile.swift b/Sources/Placements/Adapty+FallbackFile.swift index 139d8745e..77fe2a3bf 100644 --- a/Sources/Placements/Adapty+FallbackFile.swift +++ b/Sources/Placements/Adapty+FallbackFile.swift @@ -34,7 +34,7 @@ extension PlacementStorage { private func getPlacement( byPlacementId placementId: String, withVariationId variationId: String?, - profileId _: String, + userId _: AdaptyUserId, locale: AdaptyLocale ) -> AdaptyPlacementChosen? { getPlacementByLocale(locale, orDefaultLocale: true, withPlacementId: placementId, withVariationId: variationId).map { @@ -45,14 +45,14 @@ extension PlacementStorage { func getPlacementWithFallback( byPlacementId placementId: String, withVariationId variationId: String?, - profileId: String, + userId: AdaptyUserId, locale: AdaptyLocale ) -> AdaptyPlacementChosen? { let cachedA: AdaptyPlacementChosen? = variationId == nil ? nil - : getPlacement(byPlacementId: placementId, withVariationId: variationId, profileId: profileId, locale: locale) + : getPlacement(byPlacementId: placementId, withVariationId: variationId, userId: userId, locale: locale) let cachedB: AdaptyPlacementChosen? = cachedA != nil ? nil - : getPlacement(byPlacementId: placementId, withVariationId: nil, profileId: profileId, locale: locale) + : getPlacement(byPlacementId: placementId, withVariationId: nil, userId: userId, locale: locale) let cached = cachedA ?? cachedB @@ -64,12 +64,12 @@ extension PlacementStorage { } switch (cachedA, cachedB) { - case let (.some(cached), _): - if cached.content.placement.version < fallbackFile.version, + case let (cached?, _): + if fallbackFile.version > cached.content.placement.version, let fallbacked: AdaptyPlacementChosen = fallbackFile.getPlacement( byPlacementId: placementId, withVariationId: variationId, - profileId: profileId, + userId: userId, requestLocale: locale ) { @@ -80,12 +80,12 @@ extension PlacementStorage { return cached } - case let (_, .some(cached)): + case let (_, cached?): let fallBackedA: AdaptyPlacementChosen? = variationId == nil ? nil : fallbackFile.getPlacement( byPlacementId: placementId, withVariationId: variationId, - profileId: profileId, + userId: userId, requestLocale: locale ) @@ -93,7 +93,7 @@ extension PlacementStorage { : fallbackFile.getPlacement( byPlacementId: placementId, withVariationId: nil, - profileId: profileId, + userId: userId, requestLocale: locale ) @@ -112,19 +112,19 @@ extension PlacementStorage { fallBacked = fallbackFile.getPlacement( byPlacementId: placementId, withVariationId: variationId, - profileId: profileId, + userId: userId, requestLocale: locale ) ?? fallbackFile.getPlacement( byPlacementId: placementId, withVariationId: nil, - profileId: profileId, + userId: userId, requestLocale: locale ) } else { fallBacked = fallbackFile.getPlacement( byPlacementId: placementId, withVariationId: nil, - profileId: profileId, + userId: userId, requestLocale: locale ) } diff --git a/Sources/Placements/Adapty+PaywallProducts.swift b/Sources/Placements/Adapty+PaywallProducts.swift index c055cbda7..09011205f 100644 --- a/Sources/Placements/Adapty+PaywallProducts.swift +++ b/Sources/Placements/Adapty+PaywallProducts.swift @@ -65,8 +65,8 @@ public extension Adapty { } package nonisolated static func getPaywallProduct( - vendorProductId: String, adaptyProductId: String, + productInfo: BackendProductInfo, paywallProductIndex: Int, subscriptionOfferIdentifier: AdaptySubscriptionOffer.Identifier?, variationId: String, @@ -78,11 +78,11 @@ public extension Adapty { if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { guard let manager = sdk.productsManager as? SK2ProductsManager else { - throw AdaptyError.cantMakePayments() + throw .cantMakePayments() } return try await sdk.getSK2PaywallProduct( - vendorProductId: vendorProductId, adaptyProductId: adaptyProductId, + productInfo: productInfo, paywallProductIndex: paywallProductIndex, subscriptionOfferIdentifier: subscriptionOfferIdentifier, variationId: variationId, @@ -94,11 +94,11 @@ public extension Adapty { } else { guard let manager = sdk.productsManager as? SK1ProductsManager else { - throw AdaptyError.cantMakePayments() + throw .cantMakePayments() } return try await sdk.getSK1PaywallProduct( - vendorProductId: vendorProductId, adaptyProductId: adaptyProductId, + productInfo: productInfo, paywallProductIndex: paywallProductIndex, subscriptionOfferIdentifier: subscriptionOfferIdentifier, variationId: variationId, @@ -113,6 +113,6 @@ public extension Adapty { package nonisolated static func persistOnboardingVariationId( _ variationId: String ) async { - await Adapty.optionalSDK?.variationIdStorage.setOnboardingVariationId(variationId) + await Adapty.optionalSDK?.purchasePayloadStorage.setOnboardingVariationId(variationId) } } diff --git a/Sources/Placements/Adapty+PlacementForDefaultAudience.swift b/Sources/Placements/Adapty+PlacementForDefaultAudience.swift index e2bd405e3..85daeaeb3 100644 --- a/Sources/Placements/Adapty+PlacementForDefaultAudience.swift +++ b/Sources/Placements/Adapty+PlacementForDefaultAudience.swift @@ -24,7 +24,9 @@ extension Adapty { locale: String? = nil, fetchPolicy: AdaptyPlacementFetchPolicy = .default ) async throws(AdaptyError) -> AdaptyPaywall { - let locale = locale.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let locale = locale.trimmed.nonEmptyOrNil.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let placementId = placementId.trimmed + // TODO: throw error if placementId isEmpty let logParams: EventParameters = [ "placement_id": placementId, @@ -49,7 +51,9 @@ extension Adapty { locale: String? = nil, fetchPolicy: AdaptyPlacementFetchPolicy = .default ) async throws(AdaptyError) -> AdaptyOnboarding { - let locale = locale.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let locale = locale.trimmed.nonEmptyOrNil.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let placementId = placementId.trimmed + // TODO: throw error if placementId isEmpty let logParams: EventParameters = [ "placement_id": placementId, @@ -74,11 +78,11 @@ extension Adapty { _ fetchPolicy: AdaptyPlacementFetchPolicy ) async throws(AdaptyError) -> Content { let manager = profileManager - let profileId = manager?.profileId ?? profileStorage.profileId + let userId = manager?.userId ?? profileStorage.userId let fetchTask = Task.withResult { () async throws(AdaptyError) -> Content in let content: Content = try await self.fetchPlacementForDefaultAudience( - profileId, + userId, placementId, locale ) @@ -102,22 +106,22 @@ extension Adapty { } private func fetchPlacementForDefaultAudience( - _ profileId: String, + _ userId: AdaptyUserId, _ placementId: String, _ locale: AdaptyLocale ) async throws(AdaptyError) -> Content { let (cached, isTestUser): (Content?, Bool) = { - guard let manager = tryProfileManagerOrNil(with: profileId) else { return (nil, false) } + guard let manager = try? profileManager(withProfileId: userId) else { return (nil, false) } return ( manager.placementStorage.getPlacementByLocale(locale, orDefaultLocale: false, withPlacementId: placementId, withVariationId: nil)?.value, - manager.profile.value.isTestUser + manager.isTestUser ) }() do { var chosen: AdaptyPlacementChosen = try await httpConfigsSession.fetchPlacementVariationsForDefaultAudience( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, cached: cached, @@ -127,7 +131,7 @@ extension Adapty { timeoutInterval: nil ) - if let manager = tryProfileManagerOrNil(with: profileId) { + if let manager = try? profileManager(withProfileId: userId) { chosen = manager.placementStorage.savedPlacementChosen(chosen) } @@ -136,7 +140,7 @@ extension Adapty { } catch { guard let content: Content = getCacheOrFallbackFilePlacement( - profileId, + userId, placementId, locale, withCrossPlacmentABTest: false diff --git a/Sources/Placements/Adapty+Placements.swift b/Sources/Placements/Adapty+Placements.swift index 3849c98cf..175c05fe6 100644 --- a/Sources/Placements/Adapty+Placements.swift +++ b/Sources/Placements/Adapty+Placements.swift @@ -30,7 +30,9 @@ extension Adapty { loadTimeout: TimeInterval? = nil ) async throws(AdaptyError) -> AdaptyPaywall { let loadTimeout = (loadTimeout ?? .defaultLoadPlacementTimeout).allowedLoadPlacementTimeout - let locale = locale.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let locale = locale.trimmed.nonEmptyOrNil.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let placementId = placementId.trimmed + // TODO: throw error if placementId isEmpty let logParams: EventParameters = [ "placement_id": placementId, @@ -59,7 +61,9 @@ extension Adapty { loadTimeout: TimeInterval? = nil ) async throws(AdaptyError) -> AdaptyOnboarding { let loadTimeout = (loadTimeout ?? .defaultLoadPlacementTimeout).allowedLoadPlacementTimeout - let locale = locale.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let locale = locale.trimmed.nonEmptyOrNil.map { AdaptyLocale(id: $0) } ?? .defaultPlacementLocale + let placementId = placementId.trimmed + // TODO: throw error if placementId isEmpty let logParams: EventParameters = [ "placement_id": placementId, @@ -96,10 +100,8 @@ extension Adapty { } catch { if error.isProfileWasChanged { throw error } - let profileId = profileStorage.profileId - if let content: Content = getCacheOrFallbackFilePlacement( - profileId, + profileStorage.userId, placementId, locale, withCrossPlacmentABTest: true @@ -117,34 +119,35 @@ extension Adapty { _ fetchPolicy: AdaptyPlacementFetchPolicy, _ loadTimeout: TaskDuration ) async throws(AdaptyError) -> Content { - var profileId = profileStorage.profileId + var userId = profileStorage.userId let startTaskTime = Date() do { return try await withThrowingTimeout(loadTimeout - .milliseconds(500)) { - let manager = try await self.createdProfileManager - if profileId != manager.profileId { - log.verbose("fetchPlacementOrFallbackPlacement: profileId changed from \(profileId) to \(manager.profileId)") - profileId = manager.profileId + let createdUserId = try await self.createdProfileManager.userId + if createdUserId.isNotEqualProfileId(userId) { + log.verbose("fetchPlacementOrFallbackPlacement: profile changed from \(userId) to \(createdUserId)") + userId = createdUserId } + return try await self.fetchPlacement( placementId, locale, withPolicy: fetchPolicy, - forProfileId: profileId + forUserId: userId ) } } catch let error as AdaptyError { guard error.canUseFallbackServer else { throw error } } catch { - guard error is TimeoutError else { throw AdaptyError.unknown(error) } + guard error is TimeoutError else { throw .unknown(error) } } return try await fetchFallbackPlacement( placementId, locale, - forProfileId: profileId, + forUserId: userId, withTimeout: loadTimeout.asTimeInterval + startTaskTime.timeIntervalSinceNow ) } @@ -153,16 +156,16 @@ extension Adapty { _ placementId: String, _ locale: AdaptyLocale, withPolicy fetchPolicy: AdaptyPlacementFetchPolicy, - forProfileId profileId: String + forUserId userId: AdaptyUserId ) async throws(AdaptyError) -> Content { - let manager = try profileManager(with: profileId).orThrows + let manager = try profileManager(withProfileId: userId).orThrows let fetchTask: AdaptyResultTask = Task { do throws(AdaptyError) { let value: Content = try await fetchPlacement( placementId, locale, - forProfileId: profileId + forUserId: userId ) return .success(value) } catch { @@ -175,7 +178,7 @@ extension Adapty { .getPlacementByLocale(locale, orDefaultLocale: true, withPlacementId: placementId, - withVariationId: manager.storage.crossPlacementState?.variationId(placementId: placementId))? + withVariationId: manager.crossPlacmentStorage.state?.variationId(placementId: placementId))? .withFetchPolicy(fetchPolicy)? .value @@ -194,12 +197,12 @@ extension Adapty { private func fetchPlacement( _ placementId: String, _ locale: AdaptyLocale, - forProfileId profileId: String + forUserId userId: AdaptyUserId ) async throws(AdaptyError) -> Content { while true { let (segmentId, cached, isTestUser, crossPlacementState, variationId) = try { () throws(AdaptyError) in - let manager = try profileManager(with: profileId).orThrows - let crossPlacementState = manager.storage.crossPlacementState + let manager = try profileManager(withProfileId: userId).orThrows + let crossPlacementState = manager.crossPlacmentStorage.state let variationId = crossPlacementState?.variationId(placementId: placementId) let cached: Content? = manager.placementStorage .getPlacementByLocale( @@ -210,9 +213,9 @@ extension Adapty { )? .value return ( - manager.profile.value.segmentId, + manager.segmentId, cached, - manager.profile.value.isTestUser, + manager.isTestUser, crossPlacementState, variationId ) @@ -226,7 +229,7 @@ extension Adapty { if let variationId { chosen = try await httpSession.fetchPlacement( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, variationId: variationId, locale: locale, @@ -236,7 +239,7 @@ extension Adapty { } else if let crossPlacementState, crossPlacementState.canParticipateInABTest { chosen = try await httpSession.fetchPlacementVariations( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, segmentId: segmentId, @@ -244,10 +247,10 @@ extension Adapty { crossPlacementEligible: true, variationIdResolver: { @AdaptyActor placementId, draw in var crossPlacementState = crossPlacementState - let manager = self.tryProfileManagerOrNil(with: draw.profileId) + let manager = try? self.profileManager(withProfileId: draw.userId) if let manager { - guard let state = manager.storage.crossPlacementState else { + guard let state = manager.crossPlacmentStorage.state else { // We are prohibited from participating in Cross AB Tests if draw.participatesInCrossPlacementABTest { Log.crossAB.verbose("Cross-AB-test placementId = \(placementId), DISABLED -> repeat") @@ -263,7 +266,7 @@ extension Adapty { if crossPlacementState.canParticipateInABTest { if draw.participatesInCrossPlacementABTest { Log.crossAB.verbose("Cross-AB-test placementId = \(placementId), BEGIN -> variationId = \(draw.content.variationId), state = \(draw.variationIdByPlacements) DRAW") - manager?.storage.setCrossPlacementState(.init( + manager?.crossPlacmentStorage.setState(.init( variationIdByPlacements: draw.variationIdByPlacements, version: crossPlacementState.version )) @@ -294,7 +297,7 @@ extension Adapty { } else { chosen = try await httpSession.fetchPlacementVariations( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, segmentId: segmentId, @@ -305,7 +308,7 @@ extension Adapty { ) } - if let manager = tryProfileManagerOrNil(with: profileId) { + if let manager = try? profileManager(withProfileId: userId) { chosen = manager.placementStorage.savedPlacementChosen(chosen) } @@ -320,39 +323,39 @@ extension Adapty { if error.responseDecodingError([.notFoundVariationId, .crossPlacementABTestDisabled]) { continue } guard Backend.wrongProfileSegmentId(error), - try await updateSegmentId(for: profileId, oldSegmentId: segmentId) + try await updateSegmentId(for: userId, oldSegmentId: segmentId) else { throw error.asAdaptyError } } } - func updateSegmentId(for profileId: String, oldSegmentId: String) async throws(AdaptyError) -> Bool { - let manager = try profileManager(with: profileId).orThrows - guard manager.profile.value.segmentId == oldSegmentId else { return true } - return await manager.getProfile().segmentId != oldSegmentId + func updateSegmentId(for userId: AdaptyUserId, oldSegmentId: String) async throws(AdaptyError) -> Bool { + let manager = try profileManager(withProfileId: userId).orThrows + guard manager.segmentId == oldSegmentId else { return true } + return await manager.fetchSegmentId() != oldSegmentId } } func getCacheOrFallbackFilePlacement( - _ profileId: String, + _ userId: AdaptyUserId, _ placementId: String, _ locale: AdaptyLocale, withCrossPlacmentABTest: Bool ) -> Content? { let chosen: AdaptyPlacementChosen? - if let manager = tryProfileManagerOrNil(with: profileId) { + if let manager = try? profileManager(withProfileId: userId) { chosen = manager.placementStorage.getPlacementWithFallback( byPlacementId: placementId, - withVariationId: withCrossPlacmentABTest ? manager.storage.crossPlacementState?.variationId(placementId: placementId) : nil, - profileId: profileId, + withVariationId: withCrossPlacmentABTest ? manager.crossPlacmentStorage.state?.variationId(placementId: placementId) : nil, + userId: userId, locale: locale ) } else { chosen = Adapty.fallbackPlacements?.getPlacement( byPlacementId: placementId, withVariationId: nil, - profileId: profileId, + userId: userId, requestLocale: locale ) } @@ -366,27 +369,31 @@ extension Adapty { private func fetchFallbackPlacement( _ placementId: String, _ locale: AdaptyLocale, - forProfileId profileId: String, + forUserId userId: AdaptyUserId, withTimeout timeoutInterval: TimeInterval? ) async throws(AdaptyError) -> Content { var params: (cached: Content?, isTestUser: Bool, variationId: String?)? while true { params = { - guard let manager = tryProfileManagerOrNil(with: profileId) else { return nil } - let variationId = manager.storage.crossPlacementState?.variationId(placementId: placementId) + guard let manager = try? profileManager(withProfileId: userId) else { return nil } + let variationId = manager.crossPlacmentStorage.state?.variationId(placementId: placementId) return ( cached: manager.placementStorage.getPlacementByLocale(locale, orDefaultLocale: false, withPlacementId: placementId, withVariationId: variationId)?.value, - isTestUser: manager.profile.value.isTestUser, + isTestUser: manager.isTestUser, variationId: variationId ) }() ?? params + if let cached = params?.cached { + return cached + } + do { var chosen: AdaptyPlacementChosen if let variationId = params?.variationId { chosen = try await httpFallbackSession.fetchFallbackPlacement( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, paywallVariationId: variationId, locale: locale, @@ -397,7 +404,7 @@ extension Adapty { } else { chosen = try await httpFallbackSession.fetchFallbackPlacementVariations( apiKeyPrefix: apiKeyPrefix, - profileId: profileId, + userId: userId, placementId: placementId, locale: locale, cached: params?.cached, @@ -408,7 +415,7 @@ extension Adapty { ) } - if let manager = tryProfileManagerOrNil(with: profileId) { + if let manager = try? profileManager(withProfileId: userId) { chosen = manager.placementStorage.savedPlacementChosen(chosen) } diff --git a/Sources/Placements/Entities/AdaptyPaywall.ProductReference.swift b/Sources/Placements/Entities/AdaptyPaywall.ProductReference.swift index 33fd12f3f..f6dbcd371 100644 --- a/Sources/Placements/Entities/AdaptyPaywall.ProductReference.swift +++ b/Sources/Placements/Entities/AdaptyPaywall.ProductReference.swift @@ -11,7 +11,7 @@ extension AdaptyPaywall { struct ProductReference: Sendable, Hashable { let paywallProductIndex: Int let adaptyProductId: String - let vendorId: String + let productInfo: BackendProductInfo let promotionalOfferId: String? let winBackOfferId: String? } @@ -19,7 +19,7 @@ extension AdaptyPaywall { extension AdaptyPaywall.ProductReference: CustomStringConvertible { public var description: String { - "(vendorId: \(vendorId), adaptyProductId: \(adaptyProductId), promotionalOfferId: \(promotionalOfferId ?? "nil")))" + "(vendorId: \(productInfo.vendorId), adaptyProductId: \(adaptyProductId), promotionalOfferId: \(promotionalOfferId ?? "nil")))" } } @@ -30,14 +30,19 @@ extension AdaptyPaywall.ProductReference: Encodable { case promotionalOfferEligibility = "promotional_offer_eligibility" case promotionalOfferId = "promotional_offer_id" case winBackOfferId = "win_back_offer_id" + case accessLevelId = "access_level_id" + case backendProductPeriod = "product_type" } init(from container: KeyedDecodingContainer, index: Int) throws { self.paywallProductIndex = index - self.adaptyProductId = try container.decode(String.self, forKey: .adaptyProductId) - self.vendorId = try container.decode(String.self, forKey: .vendorId) self.winBackOfferId = try container.decodeIfPresent(String.self, forKey: .winBackOfferId) - + self.adaptyProductId = try container.decode(String.self, forKey: .adaptyProductId) + self.productInfo = try BackendProductInfo( + vendorId: container.decode(String.self, forKey: .vendorId), + accessLevelId: container.decode(String.self, forKey: .accessLevelId), + period: container.decode(BackendProductInfo.Period.self, forKey: .backendProductPeriod) + ) self.promotionalOfferId = if (try? container.decode(Bool.self, forKey: .promotionalOfferEligibility)) ?? true { try container.decodeIfPresent(String.self, forKey: .promotionalOfferId) @@ -48,9 +53,11 @@ extension AdaptyPaywall.ProductReference: Encodable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(vendorId, forKey: .vendorId) + try container.encode(productInfo.vendorId, forKey: .vendorId) try container.encode(adaptyProductId, forKey: .adaptyProductId) try container.encodeIfPresent(promotionalOfferId, forKey: .promotionalOfferId) try container.encodeIfPresent(winBackOfferId, forKey: .winBackOfferId) + try container.encode(productInfo.accessLevelId, forKey: .accessLevelId) + try container.encode(productInfo.period, forKey: .backendProductPeriod) } } diff --git a/Sources/Placements/Entities/AdaptyPaywall.ViewConfiguration.swift b/Sources/Placements/Entities/AdaptyPaywall.ViewConfiguration.swift index 9743894ac..75ee67637 100644 --- a/Sources/Placements/Entities/AdaptyPaywall.ViewConfiguration.swift +++ b/Sources/Placements/Entities/AdaptyPaywall.ViewConfiguration.swift @@ -36,7 +36,7 @@ extension AdaptyViewSource { do { self = try Storage.decoder.decode(AdaptyViewSource.self, from: data) } catch { - throw AdaptyError.decodingViewConfiguration(error) + throw .decodingViewConfiguration(error) } } } diff --git a/Sources/Placements/Entities/AdaptyPaywall.swift b/Sources/Placements/Entities/AdaptyPaywall.swift index edca60007..a111af8a4 100644 --- a/Sources/Placements/Entities/AdaptyPaywall.swift +++ b/Sources/Placements/Entities/AdaptyPaywall.swift @@ -30,7 +30,7 @@ public struct AdaptyPaywall: PlacementContent, WebPaywallURLProviding { package var webPaywallBaseUrl: URL? /// Array of related products ids. - public var vendorProductIds: [String] { products.map { $0.vendorId } } + public var vendorProductIds: [String] { products.map { $0.productInfo.vendorId } } var requestLocale: AdaptyLocale } diff --git a/Sources/Placements/Entities/AdaptyPaywallProduct.swift b/Sources/Placements/Entities/AdaptyPaywallProduct.swift index 0437cb25f..bbd46981f 100644 --- a/Sources/Placements/Entities/AdaptyPaywallProduct.swift +++ b/Sources/Placements/Entities/AdaptyPaywallProduct.swift @@ -10,6 +10,10 @@ import StoreKit public protocol AdaptyPaywallProductWithoutDeterminingOffer: AdaptyProduct { var adaptyProductId: String { get } + var accessLevelId: String { get } + + var adaptyProductType: String { get } + var paywallProductIndex: Int { get } /// Same as `variationId` property of the parent AdaptyPaywall. diff --git a/Sources/Placements/Entities/AdaptyPlacement.Draw.swift b/Sources/Placements/Entities/AdaptyPlacement.Draw.swift index db9152712..13687376f 100644 --- a/Sources/Placements/Entities/AdaptyPlacement.Draw.swift +++ b/Sources/Placements/Entities/AdaptyPlacement.Draw.swift @@ -9,7 +9,7 @@ import Foundation extension AdaptyPlacement { struct Draw: Sendable { - let profileId: String + let userId: AdaptyUserId var content: Content let placementAudienceVersionId: String let variationIdByPlacements: [String: String] @@ -17,18 +17,18 @@ extension AdaptyPlacement { } extension AdaptyPlacement.Draw { - var participatesInCrossPlacementABTest: Bool { !variationIdByPlacements.isEmpty } + var participatesInCrossPlacementABTest: Bool { variationIdByPlacements.isNotEmpty } } extension AdaptyPlacement.Draw: Decodable { init(from decoder: Decoder) throws { - let profileId = try decoder.userInfo.profileId + let userId = try decoder.userInfo.userId let placement = try decoder.userInfo.placement let placementAudienceVersionId = placement.audienceVersionId let variations = try [AdaptyPlacement.Variation](from: decoder) - guard !variations.isEmpty else { + guard variations.isNotEmpty else { throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Placements contents collection is empty")) } @@ -41,7 +41,7 @@ extension AdaptyPlacement.Draw: Decodable { } else { index = variations.draw( placementAudienceVersionId: placementAudienceVersionId, - profileId: profileId + userId: userId ) } @@ -54,7 +54,7 @@ extension AdaptyPlacement.Draw: Decodable { let content = try Self.content(from: decoder, index: index) self.init( - profileId: profileId, + userId: userId, content: content, placementAudienceVersionId: placementAudienceVersionId, variationIdByPlacements: variation.variationIdByPlacements diff --git a/Sources/Placements/Entities/AdaptyPlacement.Variation.swift b/Sources/Placements/Entities/AdaptyPlacement.Variation.swift index 978d7d8e7..3ae7a2899 100644 --- a/Sources/Placements/Entities/AdaptyPlacement.Variation.swift +++ b/Sources/Placements/Entities/AdaptyPlacement.Variation.swift @@ -24,7 +24,7 @@ extension AdaptyPlacement { } var isCrossPlacementTest: Bool { - !variationIdByPlacements.isEmpty + variationIdByPlacements.isNotEmpty } init(from decoder: Decoder) throws { @@ -41,7 +41,8 @@ extension AdaptyPlacement { weight = try container.decode(Int.self, forKey: .weight) if container.contains(.crossPlacementInfo), - !((try? container.decodeNil(forKey: .crossPlacementInfo)) ?? true) { + !((try? container.decodeNil(forKey: .crossPlacementInfo)) ?? true) + { let crossPlacementInfo = try container.nestedContainer(keyedBy: CrossPlacementCodingKeys.self, forKey: .crossPlacementInfo) variationIdByPlacements = try crossPlacementInfo.decode([String: String].self, forKey: .variationIdByPlacements) } else { @@ -54,12 +55,12 @@ extension AdaptyPlacement { extension [AdaptyPlacement.Variation] { func draw( placementAudienceVersionId: String, - profileId: String + userId: AdaptyUserId ) -> Int { let countVariations = self.count guard countVariations > 1 else { return 0 } - let data = Data("\(placementAudienceVersionId)-\(profileId)".md5.suffix(8)) + let data = Data("\(placementAudienceVersionId)-\(userId.profileId)".md5.suffix(8)) let value: UInt64 = data.withUnsafeBytes { $0.load(as: UInt64.self).bigEndian } var weight = Int(value % 100) diff --git a/Sources/Placements/Entities/AdaptyPlacement.swift b/Sources/Placements/Entities/AdaptyPlacement.swift index 3861f3e59..04d7057f0 100644 --- a/Sources/Placements/Entities/AdaptyPlacement.swift +++ b/Sources/Placements/Entities/AdaptyPlacement.swift @@ -28,6 +28,10 @@ extension AdaptyPlacement { placement.version = version return placement } + + func isNewerThan(_ other: AdaptyPlacement) -> Bool { + version > other.version + } } extension AdaptyPlacement: CustomStringConvertible { diff --git a/Sources/Placements/Entities/FallbackPlacements.swift b/Sources/Placements/Entities/FallbackPlacements.swift index a46e5d349..96dc33789 100644 --- a/Sources/Placements/Entities/FallbackPlacements.swift +++ b/Sources/Placements/Entities/FallbackPlacements.swift @@ -17,14 +17,14 @@ struct FallbackPlacements: Sendable { init(fileURL url: URL) throws(AdaptyError) { guard url.isFileURL else { - throw AdaptyError.isNotFileUrl() + throw .isNotFileUrl() } let decoder = FallbackPlacements.decoder() - + do { head = try decoder.decode(Head.self, from: Data(contentsOf: url)) } catch { - throw AdaptyError.decodingFallback(error) + throw .decodingFallback(error) } fileURL = url } @@ -36,7 +36,7 @@ struct FallbackPlacements: Sendable { func getPlacement( byPlacementId id: String, withVariationId: String?, - profileId: String, + userId: AdaptyUserId, requestLocale: AdaptyLocale ) -> AdaptyPlacementChosen? { guard contains(placementId: id) ?? true else { return nil } @@ -46,7 +46,7 @@ struct FallbackPlacements: Sendable { do { draw = try FallbackPlacements.decodePlacementVariation( Data(contentsOf: fileURL), - withProfileId: profileId, + withUserId: userId, withPlacementId: id, withVariationId: withVariationId, withRequestLocale: requestLocale, @@ -83,7 +83,7 @@ private extension FallbackPlacements { let formatVersion = try container.decode(Int.self, forKey: .formatVersion) guard formatVersion == Adapty.fallbackFormatVersion else { - let error = formatVersion < Adapty.fallbackFormatVersion + let error = Adapty.fallbackFormatVersion > formatVersion ? "The fallback paywalls version is not correct. Download a new one from the Adapty Dashboard." : "The fallback paywalls version is not correct. Please update the AdaptySDK." log.error(error) @@ -106,7 +106,7 @@ private extension FallbackPlacements { static func decodePlacementVariation( _ data: Data, - withProfileId profileId: String, + withUserId userId: AdaptyUserId, withPlacementId placementId: String, withVariationId variationId: String?, withRequestLocale requestLocale: AdaptyLocale, @@ -114,7 +114,7 @@ private extension FallbackPlacements { ) throws -> AdaptyPlacement.Draw? { let jsonDecoder = FallbackPlacements.decoder() jsonDecoder.userInfo.setPlacementId(placementId) - jsonDecoder.userInfo.setProfileId(profileId) + jsonDecoder.userInfo.setUserId(userId) jsonDecoder.userInfo.setRequestLocale(requestLocale) if let variationId { diff --git a/Sources/Placements/Entities/PlacementContent.swift b/Sources/Placements/Entities/PlacementContent.swift index 094da7ff8..1cf13fa49 100644 --- a/Sources/Placements/Entities/PlacementContent.swift +++ b/Sources/Placements/Entities/PlacementContent.swift @@ -37,9 +37,9 @@ extension PlacementContent { } return switch (remoteConfigLocale, viewConfigurationLocale) { - case (.none, .none): .defaultPlacementLocale - case let (.some(locale), _), - let (_, .some(locale)): locale + case (nil, nil): nil + case let (locale?, _), + let (_, locale?): locale } } diff --git a/Sources/Placements/Storage/CrossPlacementStorage.swift b/Sources/Placements/Storage/CrossPlacementStorage.swift new file mode 100644 index 000000000..01a294e78 --- /dev/null +++ b/Sources/Placements/Storage/CrossPlacementStorage.swift @@ -0,0 +1,46 @@ +// +// CrossPlacementStorage.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 21.08.2025. +// + +import Foundation + +private let log = Log.crossAB + +@AdaptyActor +final class CrossPlacementStorage { + private enum Constants { + static let crossPlacementStateKey = "AdaptySDK_Cross_Placement_State" + } + + private static let userDefaults = Storage.userDefaults + + private static var crossPlacementState: CrossPlacementState? = { + do { + return try userDefaults.getJSON(CrossPlacementState.self, forKey: Constants.crossPlacementStateKey) + } catch { + log.warn(error.localizedDescription) + return nil + } + }() + + var state: CrossPlacementState? { Self.crossPlacementState } + + func setState(_ value: CrossPlacementState) { + do { + try Self.userDefaults.setJSON(value, forKey: Constants.crossPlacementStateKey) + Self.crossPlacementState = value + log.verbose("saving crossPlacementState success = \(value)") + } catch { + log.error("saving crossPlacementState fail. \(error.localizedDescription)") + } + } + + static func clear() { + userDefaults.removeObject(forKey: Constants.crossPlacementStateKey) + crossPlacementState = nil + log.verbose("clear CrossPlacementStorage") + } +} diff --git a/Sources/Placements/Storage/OnboardingStorage.swift b/Sources/Placements/Storage/OnboardingStorage.swift index 9697a8020..d1fe47196 100644 --- a/Sources/Placements/Storage/OnboardingStorage.swift +++ b/Sources/Placements/Storage/OnboardingStorage.swift @@ -10,7 +10,7 @@ import Foundation private let log = Log.storage @AdaptyActor -final class OnboardingStorage: Sendable { +final class OnboardingStorage { private enum Constants { static let onboardingStorageKey = "AdaptySDK_Cached_Onboarding" static let onboardingStorageVersionKey = "AdaptySDK_Cached_Onboarding_Version" @@ -40,7 +40,7 @@ final class OnboardingStorage: Sendable { static func setOnboarding(_ onboarding: AdaptyOnboarding) { onboardingByPlacementId[onboarding.placement.id] = VH(onboarding, time: Date()) let array = Array(onboardingByPlacementId.values) - guard !array.isEmpty else { + guard array.isNotEmpty else { userDefaults.removeObject(forKey: Constants.onboardingStorageKey) return } @@ -65,7 +65,7 @@ final class OnboardingStorage: Sendable { private extension Sequence> { var asOnboardingByPlacementId: [String: VH] { Dictionary(map { ($0.value.placement.id, $0) }, uniquingKeysWith: { first, second in - first.value.placement.version > second.value.placement.version ? first : second + first.value.placement.isNewerThan(second.value.placement) ? first : second }) } } diff --git a/Sources/Placements/Storage/PaywallStorage.swift b/Sources/Placements/Storage/PaywallStorage.swift index 35908a5fb..0ceaf81be 100644 --- a/Sources/Placements/Storage/PaywallStorage.swift +++ b/Sources/Placements/Storage/PaywallStorage.swift @@ -10,7 +10,7 @@ import Foundation private let log = Log.storage @AdaptyActor -final class PaywallStorage: Sendable { +final class PaywallStorage { private enum Constants { static let paywallStorageKey = "AdaptySDK_Cached_Purchase_Containers" static let paywallStorageVersionKey = "AdaptySDK_Cached_Purchase_Containers_Version" @@ -40,7 +40,7 @@ final class PaywallStorage: Sendable { static func setPaywall(_ paywall: AdaptyPaywall) { paywallByPlacementId[paywall.placement.id] = VH(paywall, time: Date()) let array = Array(paywallByPlacementId.values) - guard !array.isEmpty else { + guard array.isNotEmpty else { userDefaults.removeObject(forKey: Constants.paywallStorageKey) return } @@ -65,7 +65,7 @@ final class PaywallStorage: Sendable { private extension Sequence> { var asPaywallByPlacementId: [String: VH] { Dictionary(map { ($0.value.placement.id, $0) }, uniquingKeysWith: { first, second in - first.value.placement.version > second.value.placement.version ? first : second + first.value.placement.isNewerThan(second.value.placement) ? first : second }) } } diff --git a/Sources/Placements/Storage/PlacementStorage.swift b/Sources/Placements/Storage/PlacementStorage.swift index 37f71581d..a0ab0ea51 100644 --- a/Sources/Placements/Storage/PlacementStorage.swift +++ b/Sources/Placements/Storage/PlacementStorage.swift @@ -10,7 +10,7 @@ import Foundation private let log = Log.storage @AdaptyActor -final class PlacementStorage: Sendable { +final class PlacementStorage { private static func getPlacement(_ placementId: String) -> VH? { if Content.self == AdaptyPaywall.self { return PaywallStorage.paywallByPlacementId[placementId] as? VH @@ -60,7 +60,7 @@ final class PlacementStorage: Sendable { guard var cached: Content = Self.getPlacement(content.placement.id)?.value, cached.equalLanguageCode(content), cached.variationId == content.variationId, - content.placement.version < cached.placement.version + cached.placement.isNewerThan(content.placement) else { return nil } cached.requestLocale = content.requestLocale return cached diff --git a/Sources/Profile/Adapty+Identify.swift b/Sources/Profile/Adapty+Identify.swift index 4af9dbfb9..b24339245 100644 --- a/Sources/Profile/Adapty+Identify.swift +++ b/Sources/Profile/Adapty+Identify.swift @@ -14,14 +14,17 @@ public extension Adapty { /// /// - Parameters: /// - customerUserId: User identifier in your system. - nonisolated static func identify(_ customerUserId: String, withAppAccountToken appAccountToken: UUID? = nil) async throws(AdaptyError) { - try await withActivatedSDK( - methodName: .identify, - logParams: [ - "customer_user_id": customerUserId, - "app_account_token": appAccountToken?.uuidString - ] - ) { sdk throws(AdaptyError) in + nonisolated static func identify( + _ customerUserId: String, + withAppAccountToken appAccountToken: UUID? = nil + ) async throws(AdaptyError) { + let customerUserId = customerUserId.trimmed + // TODO: throw error if customerUserId isEmpty + + try await withActivatedSDK(methodName: .identify, logParams: [ + "customerUserId": customerUserId, + "app_account_token": appAccountToken?.uuidString + ]) { sdk throws(AdaptyError) in try await sdk.identify(toCustomerUserId: customerUserId, withAppAccountToken: appAccountToken) } } diff --git a/Sources/Profile/Adapty+Profile.swift b/Sources/Profile/Adapty+Profile.swift index 5cc3a15f4..8db9ba1e5 100644 --- a/Sources/Profile/Adapty+Profile.swift +++ b/Sources/Profile/Adapty+Profile.swift @@ -13,7 +13,7 @@ public extension Adapty { /// The `getProfile` method provides the most up-to-date result as it always tries to query the API. If for some reason (e.g. no internet connection), the Adapty SDK fails to retrieve information from the server, the data from cache will be returned. It is also important to note that the Adapty SDK updates AdaptyProfile cache on a regular basis, in order to keep this information as up-to-date as possible. nonisolated static func getProfile() async throws(AdaptyError) -> AdaptyProfile { try await withActivatedSDK(methodName: .getProfile) { sdk throws(AdaptyError) in - try await sdk.createdProfileManager.getProfile() + try await sdk.createdProfileManager.fetchProfile() } } diff --git a/Sources/Profile/Adapty+SetIntegrationIdentifier.swift b/Sources/Profile/Adapty+SetIntegrationIdentifier.swift index 15af70c86..f63ed0940 100644 --- a/Sources/Profile/Adapty+SetIntegrationIdentifier.swift +++ b/Sources/Profile/Adapty+SetIntegrationIdentifier.swift @@ -12,15 +12,17 @@ extension Adapty { key: String, value: String ) async throws(AdaptyError) { + let key = key.trimmed + let value = value.trimmed + // TODO: throw error if key isEmpty + try await setIntegrationIdentifiers([key: value]) } package nonisolated static func setIntegrationIdentifiers( _ keyValues: [String: String] ) async throws(AdaptyError) { - let logParams: EventParameters = keyValues - - try await withActivatedSDK(methodName: .setIntegrationIdentifiers, logParams: logParams) { sdk throws(AdaptyError) in + try await withActivatedSDK(methodName: .setIntegrationIdentifiers, logParams: keyValues) { sdk throws(AdaptyError) in try await sdk.setIntegrationIdentifier( keyValues: keyValues ) @@ -30,11 +32,11 @@ extension Adapty { func setIntegrationIdentifier( keyValues: [String: String] ) async throws(AdaptyError) { - let profileId = try await createdProfileManager.profileId + let userId = try await createdProfileManager.userId do { try await httpSession.setIntegrationIdentifier( - profileId: profileId, + userId: userId, keyValues: keyValues ) } catch { diff --git a/Sources/Profile/Adapty+UpdateASAToken.swift b/Sources/Profile/Adapty+UpdateASAToken.swift index f1bc0ae44..209810417 100644 --- a/Sources/Profile/Adapty+UpdateASAToken.swift +++ b/Sources/Profile/Adapty+UpdateASAToken.swift @@ -15,27 +15,22 @@ extension Adapty { func updateASATokenIfNeed(for profile: VH) { guard #available(iOS 14.3, macOS 11.1, visionOS 1.0, *), - profileStorage.appleSearchAdsSyncDate == nil, // check if this is an actual first sync + (try? profileStorage.appleSearchAdsSyncDate(for: profile.userId)) == nil, // check if this is an actual first sync let attributionToken = try? Adapty.getASAToken() else { return } Task { - let profileId = profile.value.profileId + let userId = profile.userId let response = try await httpSession.sendASAToken( - profileId: profileId, + userId: userId, token: attributionToken, responseHash: profile.hash ) + handleProfileResponse(response) - if let profile = response.flatValue() { - profileManager?.saveResponse(profile) - } - - if profileStorage.profileId == profileId { - // mark appleSearchAds attribution data as synced - profileStorage.setAppleSearchAdsSyncDate() - } + // mark appleSearchAds attribution data as synced + try? profileStorage.setAppleSearchAdsSyncDate(for: userId) } } diff --git a/Sources/Profile/Adapty+UpdateAttributionData.swift b/Sources/Profile/Adapty+UpdateAttributionData.swift index 9c1f9d506..3d56466e0 100644 --- a/Sources/Profile/Adapty+UpdateAttributionData.swift +++ b/Sources/Profile/Adapty+UpdateAttributionData.swift @@ -23,7 +23,7 @@ public extension Adapty { let data = try JSONSerialization.data(withJSONObject: attribution) attributionJson = String(decoding: data, as: UTF8.self) } catch { - throw AdaptyError.wrongAttributeData(error) + throw .wrongAttributeData(error) } try await updateAttribution( @@ -36,6 +36,9 @@ public extension Adapty { _ attributionJson: String, source: String ) async throws(AdaptyError) { + let source = source.trimmed + // TODO: throw error if source isEmpty + let logParams: EventParameters = [ "source": source, ] @@ -52,23 +55,19 @@ public extension Adapty { source: String, attributionJson: String ) async throws(AdaptyError) { - let (profileId, oldResponseHash) = try await { () async throws(AdaptyError) in + let (userId, oldResponseHash) = try await { () async throws(AdaptyError) in let manager = try await createdProfileManager - return (manager.profileId, manager.profile.hash) + return (manager.userId, manager.lastResponseHash) }() do { let response = try await httpSession.setAttributionData( - profileId: profileId, + userId: userId, source: source, attributionJson: attributionJson, responseHash: oldResponseHash ) - - if let profile = response.flatValue() { - profileManager?.saveResponse(profile) - } - + handleProfileResponse(response) } catch { throw error.asAdaptyError } diff --git a/Sources/Profile/Entities/AdaptyProfile.CustomAttributes.swift b/Sources/Profile/Entities/AdaptyProfile.CustomAttributes.swift index 9912d3b8e..0b9012b04 100644 --- a/Sources/Profile/Entities/AdaptyProfile.CustomAttributes.swift +++ b/Sources/Profile/Entities/AdaptyProfile.CustomAttributes.swift @@ -38,12 +38,14 @@ extension AdaptyProfile.CustomAttributeValue { } } - func validate() -> AdaptyError? { + func validateLenght() throws(AdaptyError) { switch self { case let .string(value): - (value.isEmpty || value.count > 50) ? .wrongStringValueOfCustomAttribute() : nil - default: - nil + if value.isEmpty || value.count > 50 { throw .wrongStringValueOfCustomAttribute() } + case .none: + break + case .double: + break } } } @@ -59,18 +61,16 @@ extension AdaptyProfile.CustomAttributes { ) } - static func validateKey(_ key: String) -> AdaptyError? { + static func validateKey(_ key: String) throws(AdaptyError) { if key.isEmpty || key.count > 30 || key.range(of: ".*[^A-Za-z0-9._-].*", options: .regularExpression) != nil { - return .wrongKeyOfCustomAttribute() + throw .wrongKeyOfCustomAttribute() } - return nil } - func validate() -> AdaptyError? { + func validateCount() throws(AdaptyError) { if filter({ $1.hasValue }).count > 30 { - return .wrongCountCustomAttributes() + throw .wrongCountCustomAttributes() } - return nil } } @@ -80,7 +80,12 @@ extension AdaptyProfile.CustomAttributeValue: Codable { if container.decodeNil() { self = .none } else if let value = try? container.decode(String.self) { - self = .string(value) + if let value = value.trimmed.nonEmptyOrNil { + self = .string(value) + try validateLenght() + } else { + self = .none + } } else if let value = try? container.decode(Bool.self) { self = .double(value ? 1.0 : 0.0) } else if let value = try? container.decode(Double.self) { diff --git a/Sources/Profile/Entities/AdaptyProfile.swift b/Sources/Profile/Entities/AdaptyProfile.swift index dba48e4b6..bf0ab57b9 100644 --- a/Sources/Profile/Entities/AdaptyProfile.swift +++ b/Sources/Profile/Entities/AdaptyProfile.swift @@ -8,11 +8,13 @@ import Foundation public struct AdaptyProfile: Sendable { + let userId: AdaptyUserId + /// An identifier of a user in Adapty. - public let profileId: String + public var profileId: String { userId.profileId } /// An identifier of a user in your system. - public let customerUserId: String? + public var customerUserId: String? { userId.customerId } package let segmentId: String package let isTestUser: Bool @@ -23,7 +25,7 @@ public struct AdaptyProfile: Sendable { public let customAttributes: [String: any Sendable] /// The keys are access level identifiers configured by you in Adapty Dashboard. The values are Can be null if the customer has no access levels. - public let accessLevels: [String: AccessLevel] + public var accessLevels: [String: AccessLevel] /// The keys are product ids from a store. The values are information about subscriptions. Can be null if the customer has no subscriptions. public let subscriptions: [String: Subscription] @@ -34,10 +36,22 @@ public struct AdaptyProfile: Sendable { let version: Int64 } +extension AdaptyProfile { + func isNewerOrEqualVersion(_ other: AdaptyProfile) -> Bool { + version >= other.version + } +} + +extension VH { + @inlinable + func isNewerOrEqualVersion(_ other: VH) -> Bool { + value.isNewerOrEqualVersion(other.value) + } +} + extension AdaptyProfile: Hashable { public static func == (lhs: AdaptyProfile, rhs: AdaptyProfile) -> Bool { - lhs.profileId == rhs.profileId - && lhs.customerUserId == rhs.customerUserId + lhs.userId == rhs.userId && lhs.segmentId == rhs.segmentId && lhs.isTestUser == rhs.isTestUser && lhs.codableCustomAttributes == rhs.codableCustomAttributes @@ -48,8 +62,7 @@ extension AdaptyProfile: Hashable { } public func hash(into hasher: inout Hasher) { - hasher.combine(profileId) - hasher.combine(customerUserId) + hasher.combine(userId) hasher.combine(segmentId) hasher.combine(isTestUser) hasher.combine(codableCustomAttributes) @@ -62,8 +75,7 @@ extension AdaptyProfile: Hashable { extension AdaptyProfile: CustomStringConvertible { public var description: String { - "(profileId: \(profileId), " - + (customerUserId.map { "customerUserId: \($0), " } ?? "") + "(id: \(userId), " + "segmentId: \(segmentId), isTestuser: \(isTestUser), " + (codableCustomAttributes == nil ? "" : "customAttributes: \(customAttributes), ") + "accessLevels: \(accessLevels), subscriptions: \(subscriptions), nonSubscriptions: \(nonSubscriptions))" @@ -90,8 +102,10 @@ extension AdaptyProfile: Codable { container = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .attributes) } - profileId = try container.decode(String.self, forKey: .profileId) - customerUserId = try container.decodeIfPresent(String.self, forKey: .customerUserId) + userId = try .init( + profileId: container.decode(String.self, forKey: .profileId), + customerId: container.decodeIfPresent(String.self, forKey: .customerUserId) + ) segmentId = try container.decode(String.self, forKey: .segmentId) isTestUser = try container.decodeIfPresent(Bool.self, forKey: .isTestUser) ?? false version = try container.decodeIfPresent(Int64.self, forKey: .version) ?? 0 @@ -104,19 +118,19 @@ extension AdaptyProfile: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(profileId, forKey: .profileId) - try container.encodeIfPresent(customerUserId, forKey: .customerUserId) + try container.encode(userId.profileId, forKey: .profileId) + try container.encodeIfPresent(userId.customerId, forKey: .customerUserId) try container.encode(segmentId, forKey: .segmentId) try container.encode(isTestUser, forKey: .isTestUser) try container.encode(version, forKey: .version) try container.encodeIfPresent(codableCustomAttributes, forKey: .customAttributes) - if !accessLevels.isEmpty { + if accessLevels.isNotEmpty { try container.encode(accessLevels, forKey: .accessLevels) } - if !subscriptions.isEmpty { + if subscriptions.isNotEmpty { try container.encode(subscriptions, forKey: .subscriptions) } - if !nonSubscriptions.isEmpty { + if nonSubscriptions.isNotEmpty { try container.encode(nonSubscriptions, forKey: .nonSubscriptions) } } diff --git a/Sources/Profile/Entities/AdaptyProfileParameters.Builder.swift b/Sources/Profile/Entities/AdaptyProfileParameters.Builder.swift index 1f4cb6bcf..a14f3d5c4 100644 --- a/Sources/Profile/Entities/AdaptyProfileParameters.Builder.swift +++ b/Sources/Profile/Entities/AdaptyProfileParameters.Builder.swift @@ -16,7 +16,7 @@ public extension AdaptyProfileParameters { } init(_ values: AdaptyProfileParameters) { - parameters = values + self.parameters = values } public func build() -> AdaptyProfileParameters { parameters } @@ -26,13 +26,13 @@ public extension AdaptyProfileParameters { public extension AdaptyProfileParameters.Builder { @discardableResult func with(firstName value: String?) -> Self { - parameters.firstName = value + parameters.firstName = value.trimmed.nonEmptyOrNil return self } @discardableResult func with(lastName value: String?) -> Self { - parameters.lastName = value + parameters.lastName = value.trimmed.nonEmptyOrNil return self } @@ -57,54 +57,72 @@ public extension AdaptyProfileParameters.Builder { @discardableResult func with(email value: String?) -> Self { - parameters.email = value + parameters.email = value.trimmed.nonEmptyOrNil return self } @discardableResult func with(phoneNumber value: String?) -> Self { - parameters.phoneNumber = value + parameters.phoneNumber = value.trimmed.nonEmptyOrNil return self } -} -public extension AdaptyProfileParameters.Builder { - internal func with(customAttributes: AdaptyProfile.CustomAttributes?) -> Self { - parameters.codableCustomAttributes = customAttributes + @discardableResult + func with(analyticsDisabled value: Bool?) -> Self { + parameters.analyticsDisabled = value return self } @discardableResult - func withRemoved(customAttributeForKey key: String) throws -> Self { + func withRemoved(customAttributeForKey key: String) throws(AdaptyError) -> Self { try with(customAttribute: .none, forKey: key) } @discardableResult - func with(customAttribute value: String, forKey key: String) throws -> Self { - try with(customAttribute: .string(value), forKey: key) + func with(customAttribute value: String, forKey key: String) throws(AdaptyError) -> Self { + guard let value = value.trimmed.nonEmptyOrNil else { + return try with(customAttribute: .none, forKey: key) + } + return try with(customAttribute: .string(value), forKey: key) } @discardableResult - func with(customAttribute value: Double, forKey key: String) throws -> Self { + func with(customAttribute value: Double, forKey key: String) throws(AdaptyError) -> Self { try with(customAttribute: .double(value), forKey: key) } - internal func with(customAttribute value: AdaptyProfile.CustomAttributeValue, forKey key: String) throws(AdaptyError) -> Self { - if let error = AdaptyProfile.CustomAttributes.validateKey(key) { throw error } - if let error = value.validate() { throw error } + private func with(customAttribute value: AdaptyProfile.CustomAttributeValue, forKey key: String) throws(AdaptyError) -> Self { + let key = key.trimmed + try AdaptyProfile.CustomAttributes.validateKey(key) + try value.validateLenght() var attributes = parameters.codableCustomAttributes ?? AdaptyProfile.CustomAttributes() attributes.updateValue(value, forKey: key) - if let error = attributes.validate() { throw error } + try attributes.validateCount() parameters.codableCustomAttributes = attributes return self } } -public extension AdaptyProfileParameters.Builder { - @discardableResult - func with(analyticsDisabled value: Bool?) -> Self { - parameters.analyticsDisabled = value - return self +extension AdaptyProfileParameters.Builder: Decodable { + public convenience init(from decoder: any Decoder) throws { + var parameters = AdaptyProfileParameters() + let container = try decoder.container(keyedBy: AdaptyProfileParameters.CodingKeys.self) + + parameters.firstName = try container.decodeIfPresent(String.self, forKey: .firstName).trimmed.nonEmptyOrNil + parameters.lastName = try container.decodeIfPresent(String.self, forKey: .lastName).trimmed.nonEmptyOrNil + parameters.gender = try container.decodeIfPresent(AdaptyProfile.Gender.self, forKey: .gender) + parameters.birthday = try container.decodeIfPresent(String.self, forKey: .birthday).trimmed.nonEmptyOrNil + parameters.email = try container.decodeIfPresent(String.self, forKey: .email).trimmed.nonEmptyOrNil + parameters.phoneNumber = try container.decodeIfPresent(String.self, forKey: .phoneNumber).trimmed.nonEmptyOrNil + parameters.appTrackingTransparencyStatus = try container.decodeIfPresent(AdaptyProfileParameters.AppTrackingTransparencyStatus.self, forKey: .appTrackingTransparencyStatus) + parameters.analyticsDisabled = try container.decodeIfPresent(Bool.self, forKey: .analyticsDisabled) + + if let customAttributes = try container.decodeIfPresent(AdaptyProfile.CustomAttributes.self, forKey: .codableCustomAttributes) { + try customAttributes.validateCount() + parameters.codableCustomAttributes = customAttributes + } + + self.init(parameters) } } diff --git a/Sources/Profile/Entities/AdaptyProfileParameters.swift b/Sources/Profile/Entities/AdaptyProfileParameters.swift index 90537e5f6..1e3d640ed 100644 --- a/Sources/Profile/Entities/AdaptyProfileParameters.swift +++ b/Sources/Profile/Entities/AdaptyProfileParameters.swift @@ -48,7 +48,7 @@ public extension AdaptyProfileParameters { func builder() -> Builder { Builder(self) } } -extension AdaptyProfileParameters: Codable { +extension AdaptyProfileParameters: Encodable { enum CodingKeys: String, CodingKey { case firstName = "first_name" case lastName = "last_name" diff --git a/Sources/Profile/Entities/AdaptyUserId.swift b/Sources/Profile/Entities/AdaptyUserId.swift new file mode 100644 index 000000000..c6a97898d --- /dev/null +++ b/Sources/Profile/Entities/AdaptyUserId.swift @@ -0,0 +1,36 @@ +// +// AdaptyUserId.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 30.07.2025. +// + +struct AdaptyUserId: Sendable, Hashable { + let profileId: String + let customerId: String? +} + +extension AdaptyUserId { + var isAnonymous: Bool { customerId == nil } + + func isEqualProfileId(_ other: AdaptyUserId) -> Bool { + profileId == other.profileId + } + + func isNotEqualProfileId(_ other: AdaptyUserId) -> Bool { + !isEqualProfileId(other) + } +} + +extension AdaptyUserId: CustomStringConvertible { + var description: String { + "(profileId: \(profileId), customerUserId: \(customerId ?? "nil")" + } +} + +extension AdaptyUserId: Codable { + enum CodingKeys: String, CodingKey { + case profileId = "profile_id" + case customerId = "customer_Id" + } +} diff --git a/Sources/Profile/Entities/AdaptyUserIdentifiable.swift b/Sources/Profile/Entities/AdaptyUserIdentifiable.swift new file mode 100644 index 000000000..806127da5 --- /dev/null +++ b/Sources/Profile/Entities/AdaptyUserIdentifiable.swift @@ -0,0 +1,58 @@ +// +// AdaptyUserIdentifiable.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 01.08.2025. +// + +protocol AdaptyUserIdentifiable { + @AdaptyActor + var userId: AdaptyUserId { get } +} + +extension AdaptyUserId { + @AdaptyActor + func isEqualProfileId(_ other: AdaptyUserIdentifiable) -> Bool { + isEqualProfileId(other.userId) + } + + @AdaptyActor + func isNotEqualProfileId(_ other: AdaptyUserIdentifiable) -> Bool { + !isEqualProfileId(other.userId) + } +} + +extension AdaptyUserIdentifiable { + @AdaptyActor + var profileId: String { userId.profileId } + + @AdaptyActor + var customerUserId: String? { userId.customerId } + + @AdaptyActor + func isEqualProfileId(_ other: AdaptyUserId) -> Bool { + userId.isEqualProfileId(other) + } + + @AdaptyActor + func isNotEqualProfileId(_ other: AdaptyUserId) -> Bool { + !userId.isEqualProfileId(other) + } + + @AdaptyActor + func isEqualProfileId(_ other: AdaptyUserIdentifiable) -> Bool { + userId.isEqualProfileId(other.userId) + } + + @AdaptyActor + func isNotEqualProfileId(_ other: AdaptyUserIdentifiable) -> Bool { + !userId.isEqualProfileId(other.userId) + } +} + +extension AdaptyProfile: AdaptyUserIdentifiable {} +extension ProfileStorage: AdaptyUserIdentifiable {} +extension ProfileManager: AdaptyUserIdentifiable {} +extension VH: AdaptyUserIdentifiable { + var userId: AdaptyUserId { value.userId } +} diff --git a/Sources/Profile/Entities/CrossPlacementState.swift b/Sources/Profile/Entities/CrossPlacementState.swift index 7e2f9745c..3f175d359 100644 --- a/Sources/Profile/Entities/CrossPlacementState.swift +++ b/Sources/Profile/Entities/CrossPlacementState.swift @@ -25,8 +25,19 @@ extension CrossPlacementState { func variationId(placementId: String) -> String? { variationIdByPlacements[placementId] } + + func isNewerThan(_ other: CrossPlacementState) -> Bool { + version > other.version + } + + func isNewerThan(_ other: CrossPlacementState?) -> Bool { + guard let other else { return true } + return isNewerThan(other) + } } + + extension CrossPlacementState: CustomStringConvertible { public var description: String { "(variationIdByPlacements: \(variationIdByPlacements), version: \(version))" diff --git a/Sources/Profile/Entities/CustomerIdentityParameters.swift b/Sources/Profile/Entities/CustomerIdentityParameters.swift new file mode 100644 index 000000000..775bab6b7 --- /dev/null +++ b/Sources/Profile/Entities/CustomerIdentityParameters.swift @@ -0,0 +1,22 @@ +// +// CustomerIdentityParameters.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 02.09.2025. +// +import Foundation + +package struct CustomerIdentityParameters { + package let appAccountToken: UUID? +} + +extension CustomerIdentityParameters: Decodable { + private enum CodingKeys: String, CodingKey { + case appAccountToken = "app_account_token" + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.appAccountToken = try container.decodeIfPresent(UUID.self, forKey: .appAccountToken) + } +} diff --git a/Sources/Profile/Errors/WrongProfileIdError.swift b/Sources/Profile/Errors/WrongProfileIdError.swift new file mode 100644 index 000000000..aa603a892 --- /dev/null +++ b/Sources/Profile/Errors/WrongProfileIdError.swift @@ -0,0 +1,25 @@ +// +// WrongProfileIdError.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 05.09.2025. +// + +import Foundation + +struct WrongProfileIdError: Error { + let source: AdaptyError.Source + init( + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) { + self.source = AdaptyError.Source(file: file, function: function, line: line) + } +} + +extension WrongProfileIdError: CustomStringConvertible { + var description: String { + "WrongProfileError(\(source))" + } +} diff --git a/Sources/Profile/ProfileManager+CrossPlacementState.swift b/Sources/Profile/ProfileManager+CrossPlacementState.swift index a665a40a5..25cbd1323 100644 --- a/Sources/Profile/ProfileManager+CrossPlacementState.swift +++ b/Sources/Profile/ProfileManager+CrossPlacementState.swift @@ -6,15 +6,16 @@ // private extension Adapty { - func syncCrossPlacementState(profileId: String) async throws(AdaptyError) { + func syncCrossPlacementState(userId: AdaptyUserId) async throws(AdaptyError) { let state: CrossPlacementState + do { - state = try await httpSession.fetchCrossPlacementState(profileId: profileId) + state = try await httpSession.fetchCrossPlacementState(userId: userId) } catch { throw error.asAdaptyError } - guard let manager = try profileManager(with: profileId) else { + guard let manager = try profileManager(withProfileId: userId) else { throw .profileWasChanged() } manager.saveCrossPlacementState(state) @@ -23,18 +24,16 @@ private extension Adapty { extension ProfileManager { func syncCrossPlacementState() async throws(AdaptyError) { - try await Adapty.activatedSDK.syncCrossPlacementState(profileId: profileId) + try await Adapty.activatedSDK.syncCrossPlacementState(userId: userId) } func saveCrossPlacementState(_ newState: CrossPlacementState) { - let oldState = storage.crossPlacementState + let oldState = crossPlacmentStorage.state - if let oldState { - guard oldState.version < newState.version else { return } - } + guard newState.isNewerThan(oldState) else { return } Log.crossAB.verbose("updateProfile version = \(newState.version), newValue = \(newState.variationIdByPlacements), oldValue = \(oldState?.variationIdByPlacements.description ?? "DISABLED")") - storage.setCrossPlacementState(newState) + crossPlacmentStorage.setState(newState) } } diff --git a/Sources/Profile/ProfileManager.swift b/Sources/Profile/ProfileManager.swift index f78e8ad65..15d19cfe2 100644 --- a/Sources/Profile/ProfileManager.swift +++ b/Sources/Profile/ProfileManager.swift @@ -8,130 +8,206 @@ import Foundation @AdaptyActor -final class ProfileManager: Sendable { - nonisolated let profileId: String - var profile: VH - var onceSentEnvironment: SentEnvironment +final class ProfileManager { + let userId: AdaptyUserId - let storage: ProfileStorage + private var cachedProfile: VH + private var onceSentEnvironment: SentEnvironment + private let storage: ProfileStorage + + let crossPlacmentStorage = CrossPlacementStorage() let placementStorage = PlacementStorage() let backendIntroductoryOfferEligibilityStorage = BackendIntroductoryOfferEligibilityStorage() + var isTestUser: Bool { cachedProfile.value.isTestUser } + var segmentId: String { cachedProfile.value.segmentId } + var lastResponseHash: String? { cachedProfile.hash } + @AdaptyActor init( storage: ProfileStorage, profile: VH, sentEnvironment: SentEnvironment ) { - let profileId = profile.value.profileId - self.profileId = profileId - self.profile = profile + self.userId = profile.userId + self.cachedProfile = profile self.onceSentEnvironment = sentEnvironment self.storage = storage - Task { [weak self] in - Adapty.optionalSDK?.updateASATokenIfNeed(for: profile) + Task { @AdaptyActor [weak self] in + guard let sdk = Adapty.optionalSDK else { return } + sdk.updateASATokenIfNeed(for: profile) + + let currentProfile = await sdk.profileWithOfflineAccessLevels(profile.value) + Adapty.callDelegate { $0.didLoadLatestProfile(currentProfile) } if sentEnvironment == .none { - _ = await self?.getProfile() + _ = await self?.fetchProfile() } else { - self?.syncTransactionsIfNeed(for: profileId) + try? await sdk.syncTransactionHistory(for: profile.userId) } - - Adapty.callDelegate { $0.didLoadLatestProfile(profile.value) } } } -} -extension ProfileManager { - nonisolated func syncTransactionsIfNeed(for profileId: String) { // TODO: extract this code from ProfileManager - Task { @AdaptyActor [weak self] in - guard let sdk = Adapty.optionalSDK, - let self, - !self.storage.syncedTransactions - else { return } + fileprivate func saveProfileAndStartNotifyTask( + _ profile: VH + ) throws(WrongProfileIdError) -> Task? { + guard profile.isEqualProfileId(userId) else { throw WrongProfileIdError() } - try? await sdk.syncTransactions(for: profileId) + if profile.IsNotEqualHash(cachedProfile), + profile.isNewerOrEqualVersion(cachedProfile) + { + cachedProfile = profile } - } - func updateProfile(params: AdaptyProfileParameters) async throws(AdaptyError) -> AdaptyProfile { - try await syncProfile(params: params) + do { + try storage.updateProfile(profile) + } catch { + return nil + } + + return Task { + await notifyProfileDidChanged() + } } - func getProfile() async -> AdaptyProfile { - syncTransactionsIfNeed(for: profileId) - return await (try? syncProfile(params: nil)) ?? profile.value + fileprivate func notifyProfileDidChanged() async -> AdaptyProfile { + let profile = await profileWithOfflineAccessLevels + Adapty.callDelegate { $0.didLoadLatestProfile(profile) } + return profile } - private func syncProfile(params: AdaptyProfileParameters?) async throws(AdaptyError) -> AdaptyProfile { - if let analyticsDisabled = params?.analyticsDisabled { - storage.setExternalAnalyticsDisabled(analyticsDisabled) + /// The profile with offline access levels + var profileWithOfflineAccessLevels: AdaptyProfile { + get async { + if let sdk = Adapty.optionalSDK { + await sdk.profileWithOfflineAccessLevels(cachedProfile.value) + } else { + cachedProfile.value + } } + } - let meta = await onceSentEnvironment.getValueIfNeedSend( - analyticsDisabled: (params?.analyticsDisabled ?? false) || storage.externalAnalyticsDisabled - ) + func fetchSegmentId() async -> String { + await fetchProfile().segmentId + } - return try await Adapty.activatedSDK.syncProfile( - profile: profile, - params: params, - environmentMeta: meta - ) + func updateProfile(params: AdaptyProfileParameters) async throws(AdaptyError) -> AdaptyProfile { + if let analyticsDisabled = params.analyticsDisabled { + try? storage.setExternalAnalyticsDisabled(analyticsDisabled, for: userId) + } + return try await syncProfile(params: params) } - func saveResponse(_ newProfile: VH?) { - guard let newProfile, - profile.value.profileId == newProfile.value.profileId, - !profile.hash.nonOptionalIsEqual(newProfile.hash), - profile.value.version <= newProfile.value.version - else { return } + func fetchProfile() async -> AdaptyProfile { + if let profile = try? await syncProfile(params: nil) { + return profile + } + return await profileWithOfflineAccessLevels + } - profile = newProfile - storage.setProfile(newProfile) + private func syncProfile( + params: AdaptyProfileParameters? + ) async throws(AdaptyError) -> AdaptyProfile { + let analyticsDisabled = (params?.analyticsDisabled ?? false) + || ((try? storage.externalAnalyticsDisabled(for: userId)) ?? false) + let meta = await onceSentEnvironment.getValueIfNeedSend(analyticsDisabled: analyticsDisabled) + + let httpSession = try await Adapty.activatedSDK.httpSession + let old = cachedProfile + let response: VH? + + do throws(HTTPError) { + if params == nil, meta == nil { + Task { @AdaptyActor in + try? await Adapty.optionalSDK?.syncTransactionHistory(for: userId) + } + response = try await httpSession.fetchProfile( + userId: old.userId, + responseHash: old.hash + ) + } else { + response = try await httpSession.updateProfile( + userId: old.userId, + parameters: params, + environmentMeta: meta, + responseHash: old.hash + ) + + if let meta { + onceSentEnvironment = meta.sentEnvironment + } + } + } catch { + throw error.asAdaptyError + } + + if let response, + let profile = try? await saveProfileAndStartNotifyTask(response)?.value + { + return profile + } - Adapty.callDelegate { $0.didLoadLatestProfile(newProfile.value) } + return await profileWithOfflineAccessLevels } } extension Adapty { - func syncTransactions(for profileId: String) async throws(AdaptyError) { - let response = try await transactionManager.syncTransactions(for: profileId) + /// Handles the server profile response. + /// If the profile is newer, it will be persisted and delivered to the delegate. + /// - Parameter response: Server response containing a profile. User ID comparison is not required. + /// - Returns: if the manager is working with the same user, returns Task + func handleProfileResponse(_ response: VH?) { + guard let response else { return } + _ = try? profileManager?.saveProfileAndStartNotifyTask(response) + } - if profileStorage.profileId == profileId { - profileStorage.setSyncedTransactions(true) - } - profileManager?.saveResponse(response) + /// Handles the server profile response. + /// If the profile is newer, it will be persisted and delivered to the delegate. + /// - Parameter response: Server response containing a profile. User ID comparison is not required. + /// - Returns: if the manager is working with the same user, returns Task + func handleTransactionResponse(_ response: VH) { + try? profileStorage.setSyncedTransactionsHistory(true, for: response.userId) + _ = try? profileManager?.saveProfileAndStartNotifyTask(response) } - func saveResponse(_ newProfile: VH, syncedTransaction: Bool = false) { - if syncedTransaction, profileStorage.profileId == newProfile.value.profileId { - profileStorage.setSyncedTransactions(true) + func recalculateOfflineAccessLevels(with: SKTransaction) async -> AdaptyProfile? { + guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) else { return nil } + await (transactionManager as? SK2TransactionManager)?.clearCache() + return await profileManager?.notifyProfileDidChanged() + } + + func syncTransactionHistory(for userId: AdaptyUserId, forceSync: Bool = false) async throws(AdaptyError) { + try await transactionManager.syncUnfinishedTransactions() + + guard let syncedTransactionsHistory = try? profileStorage.syncedTransactionsHistory(for: userId) else { + return } - profileManager?.saveResponse(newProfile) + + guard forceSync || !syncedTransactionsHistory else { return } + try await transactionManager.syncTransactionHistory(for: userId) } -} -private extension Adapty { - func syncProfile(profile old: VH, params: AdaptyProfileParameters?, environmentMeta meta: Environment.Meta?) async throws(AdaptyError) -> AdaptyProfile { - let response: VH - do { - response = try await httpSession.syncProfile( - profileId: old.value.profileId, - parameters: params, - environmentMeta: meta, - responseHash: old.hash - ) - } catch { - throw error.asAdaptyError + func profileWithOfflineAccessLevels(_ serverProfile: AdaptyProfile) async -> AdaptyProfile { + guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), + let transactionManager = transactionManager as? SK2TransactionManager, + let productsManager = productsManager as? SK2ProductsManager + else { + return serverProfile } + var verifiedCurrentEntitlements = await transactionManager.getVerifiedCurrentEntitlements() - if let manager = try profileManager(with: old.value.profileId) { - if let meta { - manager.onceSentEnvironment = meta.sentEnvironment + if await !transactionManager.hasUnfinishedTransactions { + verifiedCurrentEntitlements = verifiedCurrentEntitlements.filter { + $0.unfEnvironment == SK2Transaction.xcodeEnvironment } - manager.saveResponse(response.flatValue()) } - return response.value ?? old.value + + guard !verifiedCurrentEntitlements.isEmpty else { return serverProfile } + + return await serverProfile.added( + transactions: verifiedCurrentEntitlements, + productManager: productsManager + ) } } diff --git a/Sources/Profile/Storage/ProfileStorage.swift b/Sources/Profile/Storage/ProfileStorage.swift index f0dc496d1..aa615aa8e 100644 --- a/Sources/Profile/Storage/ProfileStorage.swift +++ b/Sources/Profile/Storage/ProfileStorage.swift @@ -10,36 +10,38 @@ import Foundation private let log = Log.storage @AdaptyActor -final class ProfileStorage: Sendable { +final class ProfileStorage { private enum Constants { static let profileKey = "AdaptySDK_Purchaser_Info" static let profileIdKey = "AdaptySDK_Profile_Id" static let appAccountTokenKey = "AdaptySDK_app_account_token" static let externalAnalyticsDisabledKey = "AdaptySDK_External_Analytics_Disabled" - static let syncedTransactionsKey = "AdaptySDK_Synced_Bundle_Receipt" + static let syncedTransactionsHistoryKey = "AdaptySDK_Synced_Bundle_Receipt" static let appleSearchAdsSyncDateKey = "AdaptySDK_Apple_Search_Ads_Sync_Date" - static let crossPlacementStateKey = "AdaptySDK_Cross_Placement_State" static let lastOpenedWebPaywallKey = "AdaptySDK_Last_Opened_Web_Paywall" static let lastStartAcceleratedSyncProfileKey = "AdaptySDK_Last_Start_Accelerated_Sync_Profile" } private static let userDefaults = Storage.userDefaults - static var profileId: String = - if let identifier = userDefaults.string(forKey: Constants.profileIdKey) { - identifier + static var userId: AdaptyUserId = + if let profileId = userDefaults.string(forKey: Constants.profileIdKey) { + AdaptyUserId( + profileId: profileId, + customerId: profile?.customerUserId + ) } else { - createProfileId() + createAnonymousUserId() } - static var appAccountToken: UUID? = - userDefaults.string(forKey: Constants.appAccountTokenKey).flatMap(UUID.init) - - private static func createProfileId() -> String { + private static func createAnonymousUserId() -> AdaptyUserId { let identifier = UUID().uuidString.lowercased() - log.debug("create profileId = \(identifier)") userDefaults.set(identifier, forKey: Constants.profileIdKey) - return identifier + log.debug("create anonymous profile (profileId: \(identifier))") + return AdaptyUserId( + profileId: identifier, + customerId: nil + ) } private static var profile: VH? = { @@ -51,154 +53,203 @@ final class ProfileStorage: Sendable { } }() - private static var externalAnalyticsDisabled: Bool = userDefaults.bool(forKey: Constants.externalAnalyticsDisabledKey) - private static var syncedTransactions: Bool = userDefaults.bool(forKey: Constants.syncedTransactionsKey) - private static var appleSearchAdsSyncDate: Date? = userDefaults.object(forKey: Constants.appleSearchAdsSyncDateKey) as? Date - - private static var lastOpenedWebPaywallDate: Date? = userDefaults.object(forKey: Constants.lastOpenedWebPaywallKey) as? Date - - private static var lastStartAcceleratedSyncProfileDate: Date? = userDefaults.object(forKey: Constants.lastStartAcceleratedSyncProfileKey) as? Date - - private static var crossPlacementState: CrossPlacementState? = { + private static func setProfile(_ newProfile: VH) { + profile = newProfile do { - return try userDefaults.getJSON(CrossPlacementState.self, forKey: Constants.crossPlacementStateKey) + try userDefaults.setJSON(newProfile, forKey: Constants.profileKey) + log.debug("saving profile success.") } catch { - log.warn(error.localizedDescription) - return nil + log.error("saving profile fail. \(error.localizedDescription)") } - }() - - var profileId: String { Self.profileId } + } - func getAppAccountToken() -> UUID? { Self.appAccountToken } + private static var appAccountToken: UUID? = + userDefaults.string(forKey: Constants.appAccountTokenKey).flatMap(UUID.init) - func setAppAccountToken(_ value: UUID?) { - guard Self.appAccountToken != value else { return } - Self.appAccountToken = value + private static func setAppAccountToken(_ value: UUID?) { + guard appAccountToken != value else { return } + appAccountToken = value if let value { - Self.userDefaults.set(value.uuidString, forKey: Constants.appAccountTokenKey) + userDefaults.set(value.uuidString, forKey: Constants.appAccountTokenKey) log.debug("set appAccountToken = \(value).") } else { - Self.userDefaults.removeObject(forKey: Constants.appAccountTokenKey) + userDefaults.removeObject(forKey: Constants.appAccountTokenKey) log.debug("clear appAccountToken") } } - func getProfile() -> VH? { Self.profile } - - func setProfile(_ profile: VH) { - do { - try Self.userDefaults.setJSON(profile, forKey: Constants.profileKey) - Self.profile = profile - log.debug("saving profile success.") - } catch { - log.error("saving profile fail. \(error.localizedDescription)") - } - } - - var externalAnalyticsDisabled: Bool { Self.externalAnalyticsDisabled } + private static var externalAnalyticsDisabled: Bool = userDefaults.bool(forKey: Constants.externalAnalyticsDisabledKey) - func setExternalAnalyticsDisabled(_ value: Bool) { - guard Self.externalAnalyticsDisabled != value else { return } - Self.externalAnalyticsDisabled = value - Self.userDefaults.set(value, forKey: Constants.externalAnalyticsDisabledKey) + private static func setExternalAnalyticsDisabled(_ value: Bool) { + guard externalAnalyticsDisabled != value else { return } + externalAnalyticsDisabled = value + userDefaults.set(value, forKey: Constants.externalAnalyticsDisabledKey) log.debug("set externalAnalyticsDisabled = \(value).") } - var syncedTransactions: Bool { Self.syncedTransactions } - - func setSyncedTransactions(_ value: Bool) { - guard Self.syncedTransactions != value else { return } - Self.syncedTransactions = value - Self.userDefaults.set(value, forKey: Constants.syncedTransactionsKey) - log.debug("set syncedTransactions = \(value).") - } - - var appleSearchAdsSyncDate: Date? { Self.appleSearchAdsSyncDate } + private static var syncedTransactionsHistory: Bool = userDefaults.bool(forKey: Constants.syncedTransactionsHistoryKey) - func setAppleSearchAdsSyncDate() { - let now = Date() - Self.appleSearchAdsSyncDate = now - Self.userDefaults.set(now, forKey: Constants.appleSearchAdsSyncDateKey) - log.debug("set appleSearchAdsSyncDate = \(now).") + private static func setSyncedTransactionsHistory(_ value: Bool) { + guard syncedTransactionsHistory != value else { return } + syncedTransactionsHistory = value + userDefaults.set(value, forKey: Constants.syncedTransactionsHistoryKey) + log.debug("set syncedTransactionsHistory = \(value).") } - var crossPlacementState: CrossPlacementState? { Self.crossPlacementState } + private static var appleSearchAdsSyncDate: Date? = userDefaults.object(forKey: Constants.appleSearchAdsSyncDateKey) as? Date - func setCrossPlacementState(_ value: CrossPlacementState) { - do { - try Self.userDefaults.setJSON(value, forKey: Constants.crossPlacementStateKey) - Self.crossPlacementState = value - log.debug("saving crossPlacementState success.") - Log.crossAB.verbose("saving crossPlacementState success = \(value)") - } catch { - log.error("saving crossPlacementState fail. \(error.localizedDescription)") - } + private static func setAppleSearchAdsSyncDate(_ value: Date) { + guard appleSearchAdsSyncDate != value else { return } + appleSearchAdsSyncDate = value + userDefaults.set(value, forKey: Constants.appleSearchAdsSyncDateKey) + log.debug("set appleSearchAdsSyncDate = \(value).") } - var lastOpenedWebPaywallDate: Date? { Self.lastOpenedWebPaywallDate } + private static var lastOpenedWebPaywallDate: Date? = userDefaults.object(forKey: Constants.lastOpenedWebPaywallKey) as? Date - func setLastOpenedWebPaywallDate() { - let now = Date() - Self.lastOpenedWebPaywallDate = now - Self.userDefaults.set(now, forKey: Constants.lastOpenedWebPaywallKey) - log.debug("set lastOpenedWebPaywallDate = \(now).") + private static func setLastOpenedWebPaywallDate(_ value: Date) { + guard lastOpenedWebPaywallDate != value else { return } + lastOpenedWebPaywallDate = value + userDefaults.set(value, forKey: Constants.lastOpenedWebPaywallKey) + log.debug("set lastOpenedWebPaywallDate = \(value).") } - var lastStartAcceleratedSyncProfileDate: Date? { Self.lastStartAcceleratedSyncProfileDate } - - func setLastStartAcceleratedSyncProfileDate() { - let now = Date() - Self.lastStartAcceleratedSyncProfileDate = now - Self.userDefaults.set(now, forKey: Constants.lastStartAcceleratedSyncProfileKey) - log.debug("set setLastStartAcceleratedSyncProfileDate = \(now).") - } + private static var lastStartAcceleratedSyncProfileDate: Date? = userDefaults.object(forKey: Constants.lastStartAcceleratedSyncProfileKey) as? Date - func clearProfile(newProfileId profileId: String?) { - Self.clearProfile(newProfileId: profileId) + private static func setLastStartAcceleratedSyncProfileDate(_ value: Date) { + guard lastStartAcceleratedSyncProfileDate != value else { return } + lastStartAcceleratedSyncProfileDate = value + userDefaults.set(value, forKey: Constants.lastStartAcceleratedSyncProfileKey) + log.debug("set setLastStartAcceleratedSyncProfileDate = \(value).") } - @AdaptyActor - static func clearProfile(newProfileId profileId: String?) { - log.debug("Clear profile") - if let profileId { - userDefaults.set(profileId, forKey: Constants.profileIdKey) - Self.profileId = profileId - log.debug("set profileId = \(profileId)") + static func clearProfile(newProfile: VH? = nil) { + log.verbose("Clear profile") + if let newProfile { + userId = newProfile.userId + userDefaults.set(userId.profileId, forKey: Constants.profileIdKey) + log.verbose("set profile \(userId) ") + setProfile(newProfile) } else { - Self.profileId = createProfileId() + userId = createAnonymousUserId() + profile = nil + userDefaults.removeObject(forKey: Constants.profileKey) } userDefaults.removeObject(forKey: Constants.appAccountTokenKey) appAccountToken = nil userDefaults.removeObject(forKey: Constants.externalAnalyticsDisabledKey) externalAnalyticsDisabled = false - userDefaults.removeObject(forKey: Constants.syncedTransactionsKey) - syncedTransactions = false + userDefaults.removeObject(forKey: Constants.syncedTransactionsHistoryKey) + syncedTransactionsHistory = false userDefaults.removeObject(forKey: Constants.appleSearchAdsSyncDateKey) appleSearchAdsSyncDate = nil - userDefaults.removeObject(forKey: Constants.profileKey) - profile = nil - userDefaults.removeObject(forKey: Constants.crossPlacementStateKey) - crossPlacementState = nil userDefaults.removeObject(forKey: Constants.lastOpenedWebPaywallKey) lastOpenedWebPaywallDate = nil userDefaults.removeObject(forKey: Constants.lastStartAcceleratedSyncProfileKey) lastStartAcceleratedSyncProfileDate = nil + CrossPlacementStorage.clear() BackendIntroductoryOfferEligibilityStorage.clear() PlacementStorage.clear() } } extension ProfileStorage { + var userId: AdaptyUserId { Self.userId } + + func getProfile() -> VH? { Self.profile } + + func clearProfile(newProfile: VH? = nil) { + Self.clearProfile(newProfile: newProfile) + } + + func setIdentifiedProfile(_ newProfile: VH) { + Self.setProfile(newProfile) + Self.setSyncedTransactionsHistory(false) + } + func getProfile(withCustomerUserId customerUserId: String?) -> VH? { - guard let profile = getProfile(), - profile.value.profileId == profileId + guard let profile = Self.profile, + profile.isEqualProfileId(userId) else { return nil } guard let customerUserId else { return profile } - guard customerUserId == profile.value.customerUserId else { return nil } + guard customerUserId == profile.customerUserId else { return nil } return profile } } + +extension ProfileStorage { + private func checkProfileId(_ otherUserId: AdaptyUserId) throws(WrongProfileIdError) { + guard Self.userId.isEqualProfileId(otherUserId) else { throw WrongProfileIdError() } + } + + func updateProfile(_ profile: VH) throws(WrongProfileIdError) { + guard let stored = getProfile() else { + Self.setProfile(profile) + return + } + guard profile.isEqualProfileId(stored) else { throw WrongProfileIdError() } + + guard profile.IsNotEqualHash(stored), + profile.isNewerOrEqualVersion(stored) + else { return } + + Self.setProfile(profile) + } + + func appAccountToken() -> UUID? { + return Self.appAccountToken + } + + func setAppAccountToken(_ value: UUID?) { + Self.setAppAccountToken(value) + } + + func externalAnalyticsDisabled(for userId: AdaptyUserId) throws(WrongProfileIdError) -> Bool { + try checkProfileId(userId) + return Self.externalAnalyticsDisabled + } + + func setExternalAnalyticsDisabled(_ value: Bool, for userId: AdaptyUserId) throws(WrongProfileIdError) { + try checkProfileId(userId) + Self.setExternalAnalyticsDisabled(value) + } + + func syncedTransactionsHistory(for userId: AdaptyUserId) throws(WrongProfileIdError) -> Bool { + try checkProfileId(userId) + return Self.syncedTransactionsHistory + } + + func setSyncedTransactionsHistory(_ value: Bool, for userId: AdaptyUserId) throws(WrongProfileIdError) { + try checkProfileId(userId) + Self.setSyncedTransactionsHistory(value) + } + + func appleSearchAdsSyncDate(for userId: AdaptyUserId) throws(WrongProfileIdError) -> Date? { + try checkProfileId(userId) + return Self.appleSearchAdsSyncDate + } + + func setAppleSearchAdsSyncDate(for userId: AdaptyUserId) throws(WrongProfileIdError) { + try checkProfileId(userId) + Self.setAppleSearchAdsSyncDate(Date()) + } + + func lastOpenedWebPaywallDate() -> Date? { + Self.lastOpenedWebPaywallDate + } + + func setLastOpenedWebPaywallDate() { + Self.setLastOpenedWebPaywallDate(Date()) + } + + func lastStartAcceleratedSyncProfileDate() -> Date? { + Self.lastStartAcceleratedSyncProfileDate + } + + func setLastStartAcceleratedSyncProfileDate() { + Self.setLastStartAcceleratedSyncProfileDate(Date()) + } +} diff --git a/Sources/Storage/Entities/VH.swift b/Sources/Storage/Entities/VH.swift index 1635bb0dc..6f648226d 100644 --- a/Sources/Storage/Entities/VH.swift +++ b/Sources/Storage/Entities/VH.swift @@ -30,15 +30,13 @@ struct VH: Sendable { func mapValue(_ transform: (Value) -> U) -> VH { VH(transform(value), hash: hash, time: time) } +} +extension VH { @inlinable - func flatValue() -> VH? where Value == T? { - switch value { - case .none: - .none - case let .some(v): - VH(v, hash: hash, time: time) - } + func IsNotEqualHash(_ other: VH) -> Bool { + guard let hash = hash, let other = other.hash else { return true } + return hash != other } } diff --git a/Sources/Storage/Storage.swift b/Sources/Storage/Storage.swift index 2de1f74d8..25620ef60 100644 --- a/Sources/Storage/Storage.swift +++ b/Sources/Storage/Storage.swift @@ -21,7 +21,7 @@ final class Storage { @AdaptyActor fileprivate static let appInstallation: (identifier: String, time: Date?, appLaunchCount: Int?) = - if let identifier = userDefaults.string(forKey: Constants.appInstallationIdentifier), !identifier.isEmpty { + if let identifier = userDefaults.string(forKey: Constants.appInstallationIdentifier).nonEmptyOrNil { continueSession(installIdentifier: identifier) } else { createAppInstallation() @@ -65,10 +65,10 @@ final class Storage { if value == hash { return false } - ProfileStorage.clearProfile(newProfileId: nil) + ProfileStorage.clearProfile() await EventsStorage.clearAll() - await ProductVendorIdsStorage.clear() - await VariationIdStorage.clear() + await BackendProductInfoStorage.clear() + await PurchasePayloadStorage.clear() userDefaults.set(hash, forKey: Constants.appKeyHash) log.verbose("changing apiKeyHash = \(hash).") return true diff --git a/Sources/StoreKit/Adapty+MakePurchase.swift b/Sources/StoreKit/Adapty+MakePurchase.swift index d37e616eb..6513a9af1 100644 --- a/Sources/StoreKit/Adapty+MakePurchase.swift +++ b/Sources/StoreKit/Adapty+MakePurchase.swift @@ -27,27 +27,17 @@ public extension Adapty { "product_id": product.vendorProductId, ] ) { sdk throws(AdaptyError) in + guard let purchaser = sdk.purchaser else { throw .cantMakePayments() } + let userId = sdk.userId ?? sdk.profileStorage.userId let appAccountToken: UUID? = - if let customerUserId = sdk.customerUserId { - sdk.profileStorage.getAppAccountToken() ?? UUID(uuidString: customerUserId) + if let customerUserId = userId.customerId { + sdk.profileStorage.appAccountToken() ?? UUID(uuidString: customerUserId) } else { nil } - guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) else { - guard let manager = sdk.sk1QueueManager else { throw AdaptyError.cantMakePayments() } - - return try await manager.makePurchase( - profileId: sdk.profileStorage.profileId, - appAccountToken: appAccountToken, - product: product - ) - } - - guard let manager = sdk.sk2Purchaser else { throw AdaptyError.cantMakePayments() } - - return try await manager.makePurchase( - profileId: sdk.profileStorage.profileId, + return try await purchaser.makePurchase( + userId: userId, appAccountToken: appAccountToken, product: product ) @@ -71,7 +61,7 @@ public extension Adapty { "product_id": product.vendorProductId, ] ) { sdk throws(AdaptyError) in - guard let manager = sdk.sk1QueueManager else { throw AdaptyError.cantMakePayments() } + guard let manager = sdk.sk1QueueManager else { throw .cantMakePayments() } return try await manager.makePurchase(product: product) } } @@ -84,17 +74,9 @@ public extension Adapty { /// - Throws: An ``AdaptyError`` object nonisolated static func restorePurchases() async throws(AdaptyError) -> AdaptyProfile { try await withActivatedSDK(methodName: .restorePurchases) { sdk throws(AdaptyError) in - let profileId = sdk.profileStorage.profileId - if let response = try await sdk.transactionManager.syncTransactions(for: profileId) { - return response.value - } - let manager = try await sdk.createdProfileManager - if manager.profileId != profileId { - throw AdaptyError.profileWasChanged() - } - - return await manager.getProfile() + try await sdk.syncTransactionHistory(for: manager.userId, forceSync: true) + return await manager.fetchProfile() } } } diff --git a/Sources/StoreKit/Adapty+ReportTransaction.swift b/Sources/StoreKit/Adapty+ReportTransaction.swift index c4d8bc06e..b8fc3dc6e 100644 --- a/Sources/StoreKit/Adapty+ReportTransaction.swift +++ b/Sources/StoreKit/Adapty+ReportTransaction.swift @@ -11,18 +11,52 @@ public extension Adapty { package nonisolated static func reportTransaction( _ transactionId: String, withVariationId variationId: String? - ) async throws(AdaptyError) -> AdaptyProfile { - try await withActivatedSDK(methodName: .setVariationId, logParams: [ + ) async throws(AdaptyError) { + let variationId = variationId.trimmed.nonEmptyOrNil + let transactionId = transactionId.trimmed + // TODO: throw error if transactionId isEmpty + + return try await withActivatedSDK(methodName: .setVariationId, logParams: [ "variation_id": variationId, "transaction_id": transactionId, ]) { sdk throws(AdaptyError) in - let profileId = sdk.profileStorage.profileId - let response = try await sdk.reportTransaction( - profileId: profileId, - transactionId: transactionId, - variationId: variationId + let userId = sdk.profileStorage.userId + try await sdk.sendTransactionId( + transactionId, + with: variationId, + for: userId + ) + } + } + + private static func _reportTransaction( + _ transaction: SKTransaction, + withVariationId variationId: String? + ) async throws(AdaptyError) { + let variationId = variationId.trimmed.nonEmptyOrNil + try await withActivatedSDK(methodName: .reportSK1Transaction, logParams: [ + "variation_id": variationId, + "transaction_id": transaction.unfIdentifier, + ]) { sdk throws(AdaptyError) in + let userId = try await sdk.createdProfileManager.userId + + let productOrNil = try? await sdk.productsManager.fetchProduct( + id: transaction.unfProductId, + fetchPolicy: .returnCacheDataElseLoad + ) + + try await sdk.report( + .init( + product: productOrNil, + transaction: transaction + ), + payload: .init( + userId: userId, + paywallVariationId: variationId, + persistentOnboardingVariationId: await sdk.purchasePayloadStorage.onboardingVariationId() + ), + reason: .setVariation ) - return response.value } } @@ -31,49 +65,36 @@ public extension Adapty { /// In [Observer mode](https://docs.adapty.io/docs/ios-observer-mode), Adapty SDK doesn't know, where the purchase was made from. If you display products using our [Paywalls](https://docs.adapty.io/docs/paywall) or [A/B Tests](https://docs.adapty.io/docs/ab-test), you can manually assign variation to the purchase. After doing this, you'll be able to see metrics in Adapty Dashboard. /// /// - Parameters: - /// - sk1Transaction: A purchased transaction (note, that this method is suitable only for Store Kit version 1) [SKPaymentTransaction](https://developer.apple.com/documentation/storekit/skpaymenttransaction). + /// - transaction: A purchased transaction (note, that this method is suitable only for Store Kit version 1) [SKPaymentTransaction](https://developer.apple.com/documentation/storekit/skpaymenttransaction). /// - withVariationId: A string identifier of variation. You can get it using variationId property of ``AdaptyPaywall``. /// - Throws: An ``AdaptyError`` object nonisolated static func reportTransaction( - _ sk1Transaction: SKPaymentTransaction, + _ transaction: StoreKit.SKPaymentTransaction, withVariationId variationId: String? = nil ) async throws(AdaptyError) { - try await withActivatedSDK(methodName: .reportSK1Transaction, logParams: [ - "variation_id": variationId, - "transaction_id": sk1Transaction.transactionIdentifier, - ]) { sdk throws(AdaptyError) in - guard sk1Transaction.transactionState == .purchased || sk1Transaction.transactionState == .restored, - let id = sk1Transaction.transactionIdentifier - else { - throw AdaptyError.wrongParamPurchasedTransaction() - } - - let sk1Transaction = SK1TransactionWithIdentifier(sk1Transaction, id: id) - let profileId = try await sdk.createdProfileManager.profileId + guard transaction.transactionState == .purchased || transaction.transactionState == .restored, + let transaction = SK1TransactionWithIdentifier(transaction) + else { + throw .wrongParamPurchasedTransaction() + } - let purchasedTransaction = - if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) { - await sdk.productsManager.fillPurchasedTransaction( - paywallVariationId: variationId, - persistentPaywallVariationId: nil, - persistentOnboardingVariationId: nil, - sk1Transaction: sk1Transaction - ) - } else { - await sdk.productsManager.fillPurchasedTransaction( - paywallVariationId: variationId, - persistentPaywallVariationId: nil, - persistentOnboardingVariationId: nil, - sk1Transaction: sk1Transaction - ) - } + return try await _reportTransaction(transaction, withVariationId: variationId) + } - _ = try await sdk.validatePurchase( - profileId: profileId, - transaction: purchasedTransaction, - reason: .setVariation - ) - } + /// Link purchased transaction with paywall's variationId. + /// + /// In [Observer mode](https://docs.adapty.io/docs/ios-observer-mode), Adapty SDK doesn't know, where the purchase was made from. If you display products using our [Paywalls](https://docs.adapty.io/docs/paywall) or [A/B Tests](https://docs.adapty.io/docs/ab-test), you can manually assign variation to the purchase. After doing this, you'll be able to see metrics in Adapty Dashboard. + /// + /// - Parameters: + /// - transaction: A purchased transaction (note, that this method is suitable only for Store Kit version 2) [Transaction](https://developer.apple.com/documentation/storekit/transaction). + /// - withVariationId: A string identifier of variation. You can get it using variationId property of `AdaptyPaywall`. + /// - Throws: An ``AdaptyError`` object + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + nonisolated static func reportTransaction( + _ transaction: StoreKit.Transaction, + withVariationId variationId: String? = nil + ) async throws(AdaptyError) { + try await _reportTransaction(transaction, withVariationId: variationId) } /// Link purchased transaction with paywall's variationId. @@ -81,32 +102,46 @@ public extension Adapty { /// In [Observer mode](https://docs.adapty.io/docs/ios-observer-mode), Adapty SDK doesn't know, where the purchase was made from. If you display products using our [Paywalls](https://docs.adapty.io/docs/paywall) or [A/B Tests](https://docs.adapty.io/docs/ab-test), you can manually assign variation to the purchase. After doing this, you'll be able to see metrics in Adapty Dashboard. /// /// - Parameters: - /// - sk2Transaction: A purchased transaction (note, that this method is suitable only for Store Kit version 2) [Transaction](https://developer.apple.com/documentation/storekit/transaction). + /// - transaction: A purchased verification result of transaction (note, that this method is suitable only for Store Kit version 2) [Transaction](https://developer.apple.com/documentation/storekit/verificationresult). /// - withVariationId: A string identifier of variation. You can get it using variationId property of `AdaptyPaywall`. /// - Throws: An ``AdaptyError`` object @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) nonisolated static func reportTransaction( - _ sk2Transaction: Transaction, + _ transaction: StoreKit.VerificationResult, withVariationId variationId: String? = nil ) async throws(AdaptyError) { - try await withActivatedSDK(methodName: .reportSK2Transaction, logParams: [ - "variation_id": variationId, - "transaction_id": sk2Transaction.unfIdentifier, - ]) { sdk throws(AdaptyError) in - let profileId = try await sdk.createdProfileManager.profileId + let sk2Transaction: SK2Transaction + do { + sk2Transaction = try transaction.payloadValue + } catch { + throw StoreKitManagerError.transactionUnverified(error).asAdaptyError + } - let purchasedTransaction = await sdk.productsManager.fillPurchasedTransaction( - paywallVariationId: variationId, - persistentPaywallVariationId: nil, - persistentOnboardingVariationId: nil, - sk2Transaction: sk2Transaction - ) + return try await _reportTransaction(sk2Transaction, withVariationId: variationId) + } - _ = try await sdk.validatePurchase( - profileId: profileId, - transaction: purchasedTransaction, - reason: .setVariation - ) + /// Link product purchase result with paywall's variationId. + /// + /// In [Observer mode](https://docs.adapty.io/docs/ios-observer-mode), Adapty SDK doesn't know, where the purchase was made from. If you display products using our [Paywalls](https://docs.adapty.io/docs/paywall) or [A/B Tests](https://docs.adapty.io/docs/ab-test), you can manually assign variation to the purchase. After doing this, you'll be able to see metrics in Adapty Dashboard. + /// + /// - Parameters: + /// - purchaseResult: A product purchase result (note, that this method is suitable only for Store Kit version 2) [Product.PurchaseResult](https://developer.apple.com/documentation/storekit/product/purchaseresult). + /// - withVariationId: A string identifier of variation. You can get it using variationId property of `AdaptyPaywall`. + /// - Throws: An ``AdaptyError`` object + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + nonisolated static func reportPurchaseResult( + _ purchaseResult: StoreKit.Product.PurchaseResult, + withVariationId variationId: String? = nil + ) async throws(AdaptyError) { + switch purchaseResult { + case let .success(verificationResult): + try await reportTransaction(verificationResult, withVariationId: variationId) + case .userCancelled: + return + case .pending: + throw StoreKitManagerError.paymentPendingError().asAdaptyError + @unknown default: + throw StoreKitManagerError.productPurchaseFailed(nil).asAdaptyError } } } diff --git a/Sources/StoreKit/Adapty+UnfinishedTransaction.swift b/Sources/StoreKit/Adapty+UnfinishedTransaction.swift new file mode 100644 index 000000000..2983f6ba0 --- /dev/null +++ b/Sources/StoreKit/Adapty+UnfinishedTransaction.swift @@ -0,0 +1,17 @@ +// +// Adapty+UnfinishedTransaction.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 18.09.2025. +// + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +public extension Adapty { + nonisolated static func getUnfinishedTransactions() async throws(AdaptyError) -> [AdaptyUnfinishedTransaction] { + try await withActivatedSDK(methodName: .getUnfinishedTransactions) { sdk async throws(AdaptyError) in + try await sdk.getUnfinishedTransactions() + } + } +} + + diff --git a/Sources/StoreKit/Entities.SK1/AdaptySK1PaywallProduct.swift b/Sources/StoreKit/Entities.SK1/AdaptySK1PaywallProduct.swift index 60988e4da..200eeab99 100644 --- a/Sources/StoreKit/Entities.SK1/AdaptySK1PaywallProduct.swift +++ b/Sources/StoreKit/Entities.SK1/AdaptySK1PaywallProduct.swift @@ -12,6 +12,10 @@ struct AdaptySK1PaywallProduct: AdaptySK1Product, AdaptyPaywallProduct, WebPaywa public let adaptyProductId: String + let productInfo: BackendProductInfo + public var accessLevelId: String { productInfo.accessLevelId } + public var adaptyProductType: String { productInfo.period.rawValue } + public let paywallProductIndex: Int public let subscriptionOffer: AdaptySubscriptionOffer? @@ -28,7 +32,7 @@ struct AdaptySK1PaywallProduct: AdaptySK1Product, AdaptyPaywallProduct, WebPaywa let webPaywallBaseUrl: URL? public var description: String { - "(vendorProductId: \(vendorProductId), paywallName: \(paywallName), adaptyProductId: \(adaptyProductId), variationId: \(variationId), paywallABTestName: \(paywallABTestName), subscriptionOffer:\(subscriptionOffer.map { $0.description } ?? "nil") , skProduct:\(skProduct)" + "(adaptyProductId: \(adaptyProductId), info: \(productInfo), paywallName: \(paywallName), variationId: \(variationId), paywallABTestName: \(paywallABTestName), subscriptionOffer:\(subscriptionOffer.map { $0.description } ?? "nil") , skProduct:\(skProduct)" } } @@ -37,6 +41,10 @@ struct AdaptySK1PaywallProductWithoutDeterminingOffer: AdaptySK1Product, AdaptyP public let adaptyProductId: String + let productInfo: BackendProductInfo + public var accessLevelId: String { productInfo.accessLevelId } + public var adaptyProductType: String { productInfo.period.rawValue } + public let paywallProductIndex: Int /// Same as `variationId` property of the parent AdaptyPaywall. @@ -51,6 +59,6 @@ struct AdaptySK1PaywallProductWithoutDeterminingOffer: AdaptySK1Product, AdaptyP let webPaywallBaseUrl: URL? public var description: String { - "(vendorProductId: \(vendorProductId), paywallName: \(paywallName), adaptyProductId: \(adaptyProductId), variationId: \(variationId), paywallABTestName: \(paywallABTestName), skProduct:\(skProduct)" + "(adaptyProductId: \(adaptyProductId), info: \(productInfo), paywallName: \(paywallName), variationId: \(variationId), paywallABTestName: \(paywallABTestName), skProduct:\(skProduct)" } } diff --git a/Sources/StoreKit/Entities.SK1/AdaptySK1Product.swift b/Sources/StoreKit/Entities.SK1/AdaptySK1Product.swift index 68d6a626c..e80b152a2 100644 --- a/Sources/StoreKit/Entities.SK1/AdaptySK1Product.swift +++ b/Sources/StoreKit/Entities.SK1/AdaptySK1Product.swift @@ -54,3 +54,11 @@ extension AdaptySK1Product { "(vendorProductId: \(vendorProductId), skProduct: \(skProduct))" } } + +struct SK1ProductWrapper: AdaptySK1Product { + let skProduct: SK1Product +} + +extension SK1Product { + var asAdaptyProduct: AdaptySK1Product { SK1ProductWrapper(skProduct: self) } +} diff --git a/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.OfferType.swift b/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.OfferType.swift deleted file mode 100644 index 0abda188e..000000000 --- a/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.OfferType.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// SK1Product.SubscriptionOffer.OfferType.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 23.09.2024 -// - -import StoreKit - -extension SK1Product.SubscriptionOffer { - typealias OfferType = `Type` -} diff --git a/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.PaymentMode.swift b/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.PaymentMode.swift index a5196da66..afaa27ccd 100644 --- a/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.PaymentMode.swift +++ b/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.PaymentMode.swift @@ -10,14 +10,10 @@ import StoreKit extension SK1Product.SubscriptionOffer.PaymentMode { var asPaymentMode: AdaptySubscriptionOffer.PaymentMode { switch self { - case .payAsYouGo: - .payAsYouGo - case .payUpFront: - .payUpFront - case .freeTrial: - .freeTrial - @unknown default: - .unknown + case .payAsYouGo: .payAsYouGo + case .payUpFront: .payUpFront + case .freeTrial: .freeTrial + @unknown default: .unknown } } } diff --git a/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.swift b/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.swift index 9308bbb4a..b00db5162 100644 --- a/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.swift +++ b/Sources/StoreKit/Entities.SK1/SK1Product.SubscriptionOffer.swift @@ -13,32 +13,25 @@ extension SK1Product { var introductoryOfferNotApplicable: Bool { if let period = subscriptionPeriod, period.numberOfUnits > 0, - introductoryPrice != nil { - false - } else { - true - } - } - - private var unfIntroductoryOffer: SK1Product.SubscriptionOffer? { - introductoryPrice + introductoryPrice != nil + { false } + else + { true } } - private func unfPromotionalOffer(byId identifier: String) -> SK1Product.SubscriptionOffer? { - discounts.first(where: { $0.identifier == identifier }) + func sk1ProductSubscriptionOffer(by offerIdentifier: AdaptySubscriptionOffer.Identifier) -> SubscriptionOffer? { + switch offerIdentifier { + case .introductory: + introductoryPrice + case let .promotional(id): + discounts.first(where: { $0.identifier == id }) + case .winBack: nil + case .code: nil + } } func subscriptionOffer(by offerIdentifier: AdaptySubscriptionOffer.Identifier) -> AdaptySubscriptionOffer? { - let offer: SK1Product.SubscriptionOffer? = - switch offerIdentifier { - case .introductory: - unfIntroductoryOffer - case let .promotional(id): - unfPromotionalOffer(byId: id) - default: - nil - } - guard let offer else { return nil } + guard let offer: SubscriptionOffer = sk1ProductSubscriptionOffer(by: offerIdentifier) else { return nil } let locale = priceLocale let period = offer.subscriptionPeriod.asAdaptySubscriptionPeriod @@ -56,3 +49,7 @@ extension SK1Product { ) } } + +extension SK1Product.SubscriptionOffer { + typealias OfferType = SKProductDiscount.`Type` +} diff --git a/Sources/StoreKit/Entities.SK1/SK1Transaction.swift b/Sources/StoreKit/Entities.SK1/SK1Transaction.swift index b8f17d865..a9bf1f0d8 100644 --- a/Sources/StoreKit/Entities.SK1/SK1Transaction.swift +++ b/Sources/StoreKit/Entities.SK1/SK1Transaction.swift @@ -20,14 +20,19 @@ extension SK1Transaction { } @inlinable - var unfProductID: String { payment.productIdentifier } + var unfProductId: String { payment.productIdentifier } @inlinable var unfOfferId: String? { payment.paymentDiscount?.identifier } + func logParams(other: EventParameters?) -> EventParameters { + guard let other else { return logParams } + return logParams.merging(other) { _, new in new } + } + var logParams: EventParameters { [ - "product_id": unfProductID, + "product_id": unfProductId, "state": transactionState.stringValue, "transaction_id": unfIdentifier, "original_id": unfOriginalIdentifier, @@ -39,7 +44,8 @@ struct SK1TransactionWithIdentifier: Sendable { let underlay: SK1Transaction private let id: String - init(_ underlay: SK1Transaction, id: String) { + init?(_ underlay: SK1Transaction) { + guard let id = underlay.transactionIdentifier else { return nil } self.underlay = underlay self.id = id } @@ -51,13 +57,16 @@ struct SK1TransactionWithIdentifier: Sendable { var unfOriginalIdentifier: String { underlay.unfOriginalIdentifier ?? unfIdentifier } @inlinable - var unfProductID: String { underlay.unfProductID } + var unfProductId: String { underlay.unfProductId } @inlinable var unfOfferId: String? { underlay.unfOfferId } @inlinable - var logParams: EventParameters { underlay.logParams } + func logParams(other: EventParameters?) -> EventParameters { underlay.logParams(other: other) } + + @inlinable + var logParams: EventParameters { underlay.logParams(other: nil) } } private extension SKPaymentTransactionState { diff --git a/Sources/StoreKit/Entities.SK2/AdaptySK2PaywallProduct.swift b/Sources/StoreKit/Entities.SK2/AdaptySK2PaywallProduct.swift index 89761f77d..c1d392b86 100644 --- a/Sources/StoreKit/Entities.SK2/AdaptySK2PaywallProduct.swift +++ b/Sources/StoreKit/Entities.SK2/AdaptySK2PaywallProduct.swift @@ -13,6 +13,11 @@ struct AdaptySK2PaywallProduct: AdaptySK2Product, AdaptyPaywallProduct, WebPaywa public let adaptyProductId: String + let productInfo: BackendProductInfo + + public var accessLevelId: String { productInfo.accessLevelId } + public var adaptyProductType: String { productInfo.period.rawValue } + public let paywallProductIndex: Int public let subscriptionOffer: AdaptySubscriptionOffer? @@ -29,7 +34,7 @@ struct AdaptySK2PaywallProduct: AdaptySK2Product, AdaptyPaywallProduct, WebPaywa let webPaywallBaseUrl: URL? public var description: String { - "(vendorProductId: \(vendorProductId), paywallName: \(paywallName), adaptyProductId: \(adaptyProductId), variationId: \(variationId), paywallABTestName: \(paywallABTestName), subscriptionOffer:\(subscriptionOffer.map { $0.description } ?? "nil") , skProduct:\(skProduct)" + "(adaptyProductId: \(adaptyProductId), info: \(productInfo), paywallName: \(paywallName), variationId: \(variationId), paywallABTestName: \(paywallABTestName), subscriptionOffer:\(subscriptionOffer.map { $0.description } ?? "nil") , skProduct:\(skProduct)" } } @@ -39,6 +44,10 @@ struct AdaptySK2PaywallProductWithoutDeterminingOffer: AdaptySK2Product, AdaptyP public let adaptyProductId: String + let productInfo: BackendProductInfo + public var accessLevelId: String { productInfo.accessLevelId } + public var adaptyProductType: String { productInfo.period.rawValue } + public let paywallProductIndex: Int /// Same as `variationId` property of the parent AdaptyPaywall. @@ -53,6 +62,6 @@ struct AdaptySK2PaywallProductWithoutDeterminingOffer: AdaptySK2Product, AdaptyP let webPaywallBaseUrl: URL? public var description: String { - "(vendorProductId: \(vendorProductId), paywallName: \(paywallName), adaptyProductId: \(adaptyProductId), variationId: \(variationId), paywallABTestName: \(paywallABTestName), skProduct:\(skProduct)" + "(adaptyProductId: \(adaptyProductId), info: \(productInfo), paywallName: \(paywallName), variationId: \(variationId), paywallABTestName: \(paywallABTestName), skProduct:\(skProduct)" } } diff --git a/Sources/StoreKit/Entities.SK2/AdaptySK2Product.swift b/Sources/StoreKit/Entities.SK2/AdaptySK2Product.swift index 9b6362a0c..4adf2d07b 100644 --- a/Sources/StoreKit/Entities.SK2/AdaptySK2Product.swift +++ b/Sources/StoreKit/Entities.SK2/AdaptySK2Product.swift @@ -53,3 +53,13 @@ extension AdaptySK2Product { "(vendorProductId: \(vendorProductId), skProduct: \(skProduct))" } } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +struct SK2ProductWrapper: AdaptySK2Product { + let skProduct: SK2Product +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2Product { + var asAdaptyProduct: AdaptySK2Product { SK2ProductWrapper(skProduct: self) } +} diff --git a/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.PaymentMode.swift b/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.PaymentMode.swift index 0d695e9ac..3fed19e1e 100644 --- a/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.PaymentMode.swift +++ b/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.PaymentMode.swift @@ -11,14 +11,10 @@ import StoreKit extension SK2Product.SubscriptionOffer.PaymentMode { var asPaymentMode: AdaptySubscriptionOffer.PaymentMode { switch self { - case .payAsYouGo: - .payAsYouGo - case .payUpFront: - .payUpFront - case .freeTrial: - .freeTrial - default: - .unknown + case .payAsYouGo: .payAsYouGo + case .payUpFront: .payUpFront + case .freeTrial: .freeTrial + default: .unknown } } } diff --git a/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.swift b/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.swift index 7f7e62ad2..5ee4938a8 100644 --- a/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.swift +++ b/Sources/StoreKit/Entities.SK2/SK2Product.SubscriptionOffer.swift @@ -13,33 +13,25 @@ extension SK2Product { subscription?.introductoryOffer == nil } - private var unfIntroductoryOffer: SK2Product.SubscriptionOffer? { - subscription?.introductoryOffer - } - - private func unfPromotionalOffer(byId identifier: String) -> SK2Product.SubscriptionOffer? { - subscription?.promotionalOffers.first(where: { $0.id == identifier }) - } - - func unfWinBackOffer(byId identifier: String) -> SK2Product.SubscriptionOffer? { - guard #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) else { - return nil + func sk2ProductSubscriptionOffer(by offerIdentifier: AdaptySubscriptionOffer.Identifier) -> SubscriptionOffer? { + switch offerIdentifier { + case .introductory: + subscription?.introductoryOffer + case let .promotional(offerId): + subscription?.promotionalOffers.first(where: { $0.id == offerId }) + case let .winBack(offerId): + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + subscription?.winBackOffers.first { $0.id == offerId } + } else { + nil + } + case .code: + nil } - - return subscription?.winBackOffers.first { $0.id == identifier } } func subscriptionOffer(by offerIdentifier: AdaptySubscriptionOffer.Identifier) -> AdaptySubscriptionOffer? { - let offer: SK2Product.SubscriptionOffer? = - switch offerIdentifier { - case .introductory: - unfIntroductoryOffer - case let .promotional(id): - unfPromotionalOffer(byId: id) - case let .winBack(id): - unfWinBackOffer(byId: id) - } - guard let offer else { return nil } + guard let offer: SubscriptionOffer = sk2ProductSubscriptionOffer(by: offerIdentifier) else { return nil } let period = offer.period.asAdaptySubscriptionPeriod let periodLocale = unfPeriodLocale diff --git a/Sources/StoreKit/Entities.SK2/SK2Product.swift b/Sources/StoreKit/Entities.SK2/SK2Product.swift index 018443f94..1aba7ef95 100644 --- a/Sources/StoreKit/Entities.SK2/SK2Product.swift +++ b/Sources/StoreKit/Entities.SK2/SK2Product.swift @@ -31,13 +31,7 @@ extension SK2Product { } @inlinable - var unfPriceLocale: Locale { - if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { - return priceFormatStyle.locale - } - - return .autoupdatingCurrent - } + var unfPriceLocale: Locale { priceFormatStyle.locale } @inlinable var unfPeriodLocale: Locale { diff --git a/Sources/StoreKit/Entities.SK2/SK2Transaction.Offer.PaymentMode.swift b/Sources/StoreKit/Entities.SK2/SK2Transaction.Offer.PaymentMode.swift index 567b4e726..c9e1502a3 100644 --- a/Sources/StoreKit/Entities.SK2/SK2Transaction.Offer.PaymentMode.swift +++ b/Sources/StoreKit/Entities.SK2/SK2Transaction.Offer.PaymentMode.swift @@ -11,14 +11,10 @@ import StoreKit extension SK2Transaction.Offer.PaymentMode { var asPaymentMode: AdaptySubscriptionOffer.PaymentMode { switch self { - case .payAsYouGo: - .payAsYouGo - case .payUpFront: - .payUpFront - case .freeTrial: - .freeTrial - default: - .unknown + case .payAsYouGo: .payAsYouGo + case .payUpFront: .payUpFront + case .freeTrial: .freeTrial + default: .unknown } } } diff --git a/Sources/StoreKit/Entities.SK2/SK2Transaction.OfferType.swift b/Sources/StoreKit/Entities.SK2/SK2Transaction.OfferType.swift deleted file mode 100644 index 9576728d1..000000000 --- a/Sources/StoreKit/Entities.SK2/SK2Transaction.OfferType.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// SK2Transaction.OfferType.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 23.09.2024 -// - -import StoreKit - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -extension SK2Transaction.OfferType { - var asPurchasedTransactionOfferType: PurchasedTransaction.OfferType { - guard let type = PurchasedTransaction.OfferType(rawValue: rawValue) else { - return .unknown - } - return type - } -} diff --git a/Sources/StoreKit/Entities.SK2/SK2Transaction.swift b/Sources/StoreKit/Entities.SK2/SK2Transaction.swift index 6e702ec70..8787eb98f 100644 --- a/Sources/StoreKit/Entities.SK2/SK2Transaction.swift +++ b/Sources/StoreKit/Entities.SK2/SK2Transaction.swift @@ -22,27 +22,33 @@ extension SK2Transaction { var unfOriginalIdentifier: String { String(originalID) } @inlinable - var unfProductID: String { productID } + var unfProductId: String { productID } + + func logParams(other: EventParameters?) -> EventParameters { + guard let other else { return logParams } + return logParams.merging(other) { _, new in new } + } var logParams: EventParameters { [ - "product_id": unfProductID, + "product_id": unfProductId, "transaction_is_upgraded": isUpgraded, "transaction_id": unfIdentifier, "original_id": unfOriginalIdentifier, ] } - var unfOfferType: SK2Transaction.OfferType? { + var subscriptionOfferType: AdaptySubscriptionOfferType? { if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { - return offer?.type + (offer?.type ?? offerType)?.asSubscriptionOfferType + } else { + offerType?.asSubscriptionOfferType } - return offerType } var unfOfferId: String? { if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { - return offer?.id + return offer?.id ?? offerID } return offerID } @@ -51,15 +57,19 @@ extension SK2Transaction { #if !os(visionOS) guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) else { let environment = environmentStringRepresentation - return environment.isEmpty ? "storekit2" : environment.lowercased() + return environment.lowercased() } #endif - switch environment { - case .production: return "production" - case .sandbox: return "sandbox" - case .xcode: return "xcode" - default: return environment.rawValue + return switch environment { + case .production: Self.productionEnvironment + case .sandbox: Self.sandboxEnvironment + case .xcode: Self.xcodeEnvironment + default: environment.rawValue.lowercased() } } + + static let productionEnvironment = "production" + static let sandboxEnvironment = "sandbox" + static let xcodeEnvironment = "xcode" } diff --git a/Sources/StoreKit/Entities/AdaptyProduct.swift b/Sources/StoreKit/Entities/AdaptyProduct.swift index 3537e606e..cc0691fb8 100644 --- a/Sources/StoreKit/Entities/AdaptyProduct.swift +++ b/Sources/StoreKit/Entities/AdaptyProduct.swift @@ -56,3 +56,5 @@ public protocol AdaptyProduct: Sendable, CustomStringConvertible { /// The period's language is determined by the preferred language set on the device. var localizedSubscriptionPeriod: String? { get } } + + diff --git a/Sources/StoreKit/Entities/AdaptySubscriptionOffer.Identifier.swift b/Sources/StoreKit/Entities/AdaptySubscriptionOffer.Identifier.swift index d42b569e6..6f1cbcfcb 100644 --- a/Sources/StoreKit/Entities/AdaptySubscriptionOffer.Identifier.swift +++ b/Sources/StoreKit/Entities/AdaptySubscriptionOffer.Identifier.swift @@ -7,59 +7,62 @@ import Foundation -extension AdaptySubscriptionOffer { - package enum Identifier: Sendable, Hashable { +package extension AdaptySubscriptionOffer { + enum Identifier: Sendable, Hashable { case introductory case promotional(String) case winBack(String) + case code(String?) - var identifier: String? { + package var offerId: String? { switch self { - case .introductory: - nil + case .introductory: nil case let .promotional(value), - let .winBack(value): - value + let .winBack(value): value + case let .code(value): value } } - var asOfferType: OfferType { + package var offerType: AdaptySubscriptionOfferType { switch self { - case .introductory: - .introductory - case .promotional: - .promotional - case .winBack: - .winBack + case .introductory: .introductory + case .promotional: .promotional + case .winBack: .winBack + case .code: .code } } } +} - public enum OfferType: String, Sendable { - case introductory - case promotional - case winBack +private extension AdaptySubscriptionOffer.Identifier { + init?(offerId: String?, offerType: AdaptySubscriptionOfferType) { + switch offerType { + case .introductory: + self = .introductory + case .promotional: + guard let offerId else { return nil } + self = .promotional(offerId) + case .winBack: + guard let offerId else { return nil } + self = .winBack(offerId) + case .code: + self = .code(offerId) + } } } -package extension AdaptySubscriptionOffer.OfferType { - enum CodingValues: String, Codable { - case introductory - case promotional - case winBack = "win_back" +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2Transaction { + var subscriptionOfferIdentifier: AdaptySubscriptionOffer.Identifier? { + guard let offerType = subscriptionOfferType else { return nil } + return .init(offerId: unfOfferId, offerType: offerType) } +} - var encodedValue: String { - let value: CodingValues = - switch self { - case .introductory: - .introductory - case .promotional: - .promotional - case .winBack: - .winBack - } - - return value.rawValue +@available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) +extension SK2Transaction.Offer { + var subscriptionOfferIdentifier: AdaptySubscriptionOffer.Identifier? { + guard let offerType = type.asSubscriptionOfferType else { return nil } + return .init(offerId: id, offerType: offerType) } } diff --git a/Sources/StoreKit/Entities/AdaptySubscriptionOffer.PaymentMode.swift b/Sources/StoreKit/Entities/AdaptySubscriptionOffer.PaymentMode.swift index 1762d1ef4..07f020a9f 100644 --- a/Sources/StoreKit/Entities/AdaptySubscriptionOffer.PaymentMode.swift +++ b/Sources/StoreKit/Entities/AdaptySubscriptionOffer.PaymentMode.swift @@ -16,7 +16,7 @@ public extension AdaptySubscriptionOffer { } } -extension AdaptySubscriptionOffer.PaymentMode: Encodable { +extension AdaptySubscriptionOffer.PaymentMode { private enum CodingValues: String { case payAsYouGo = "pay_as_you_go" case payUpFront = "pay_up_front" @@ -35,7 +35,9 @@ extension AdaptySubscriptionOffer.PaymentMode: Encodable { return value.map { $0.rawValue } } +} +extension AdaptySubscriptionOffer.PaymentMode: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(encodedValue ?? CodingValues.unknown.rawValue) diff --git a/Sources/StoreKit/Entities/AdaptySubscriptionOffer.swift b/Sources/StoreKit/Entities/AdaptySubscriptionOffer.swift index 785abbc94..c44776dd7 100644 --- a/Sources/StoreKit/Entities/AdaptySubscriptionOffer.swift +++ b/Sources/StoreKit/Entities/AdaptySubscriptionOffer.swift @@ -9,9 +9,9 @@ import Foundation public struct AdaptySubscriptionOffer: Sendable, Hashable { /// Unique identifier of a discount offer for a product. - public var identifier: String? { offerIdentifier.identifier } + public var identifier: String? { offerIdentifier.offerId } - public var offerType: OfferType { offerIdentifier.asOfferType } + public var offerType: AdaptySubscriptionOfferType { offerIdentifier.offerType } package let offerIdentifier: Identifier diff --git a/Sources/StoreKit/Entities/AdaptySubscriptionOfferType.swift b/Sources/StoreKit/Entities/AdaptySubscriptionOfferType.swift new file mode 100644 index 000000000..5e8d43378 --- /dev/null +++ b/Sources/StoreKit/Entities/AdaptySubscriptionOfferType.swift @@ -0,0 +1,40 @@ +// +// AdaptySubscriptionOfferType.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 05.08.2025. +// + +import StoreKit + +public enum AdaptySubscriptionOfferType: String, Sendable { + case introductory + case promotional + case winBack = "win_back" + case code +} + +extension AdaptySubscriptionOfferType: Codable {} + +extension SK1Product.SubscriptionOffer.OfferType { + var asSubscriptionOfferType: AdaptySubscriptionOfferType? { + switch self { + case .introductory: .introductory + case .subscription: .promotional + default: nil + } + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2Transaction.OfferType { + var asSubscriptionOfferType: AdaptySubscriptionOfferType? { + switch self { + case .introductory: .introductory + case .promotional: .promotional + case .winBack: .winBack + case .code: .code + default: nil + } + } +} diff --git a/Sources/StoreKit/Entities/AdaptyUnfinishedTransaction.swift b/Sources/StoreKit/Entities/AdaptyUnfinishedTransaction.swift new file mode 100644 index 000000000..bc5fff8e9 --- /dev/null +++ b/Sources/StoreKit/Entities/AdaptyUnfinishedTransaction.swift @@ -0,0 +1,52 @@ +// +// AdaptyUnfinishedTransaction.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 07.09.2025. +// +import StoreKit + +private let log = Log.sk2TransactionManager + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +public struct AdaptyUnfinishedTransaction: Sendable { + public let sk2SignedTransaction: VerificationResult + + public func finish() async throws(AdaptyError) { + try await Adapty.withActivatedSDK(methodName: .manualFinishTransaction, logParams: [ + "transaction_id": sk2Transaction.unfIdentifier, + ]) { sdk throws(AdaptyError) in + guard !sdk.observerMode else { throw AdaptyError.notAllowedInObserveMode() } + + guard case let .verified(sk2Transaction) = sk2SignedTransaction else { + return + } + + await sdk.manualFinishTransaction(sk2Transaction) + } + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +private extension Adapty { + func manualFinishTransaction(_ sk2Transaction: SK2Transaction) async { + let synced = await purchasePayloadStorage.isSyncedTransaction(sk2Transaction.unfIdentifier) + await purchasePayloadStorage.removeUnfinishedTransaction(sk2Transaction.unfIdentifier) + + if !synced { return } + + await finish(transaction: sk2Transaction, recived: .manual) + log.info("Finish unfinished transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId) after manual call method (already synchronized)") + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +public extension AdaptyUnfinishedTransaction { + var sk2Transaction: Transaction { + sk2SignedTransaction.unsafePayloadValue + } + + var jwsTransaction: String { + sk2SignedTransaction.jwsRepresentation + } +} diff --git a/Sources/StoreKit/Entities/BackendProductInfo.Period.swift b/Sources/StoreKit/Entities/BackendProductInfo.Period.swift new file mode 100644 index 000000000..b5fabc377 --- /dev/null +++ b/Sources/StoreKit/Entities/BackendProductInfo.Period.swift @@ -0,0 +1,93 @@ +// +// BackendProductInfo.Period.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 24.07.2025. +// + +import Foundation + +package extension BackendProductInfo { + enum Period: Sendable { + case weekly + case monthly + case twoMonths + case trimonthly + case semiannual + case annual + case lifetime + case consumable + case nonSubscriptions + case uncategorised(String?) + } +} + +extension BackendProductInfo.Period { + func expiresAt(startedAt: Date) -> Date? { + let calendar = { + var calendar = Calendar(identifier: .gregorian) + if let timeZone = TimeZone(abbreviation: "UTC") { + calendar.timeZone = timeZone + } + return calendar + } + return switch self { + case .weekly: calendar().date(byAdding: .day, value: 7, to: startedAt) + case .monthly: calendar().date(byAdding: .month, value: 1, to: startedAt) + case .twoMonths: calendar().date(byAdding: .month, value: 2, to: startedAt) + case .trimonthly: calendar().date(byAdding: .month, value: 3, to: startedAt) + case .semiannual: calendar().date(byAdding: .month, value: 6, to: startedAt) + case .annual: calendar().date(byAdding: .year, value: 1, to: startedAt) + default: nil + } + } +} + +extension BackendProductInfo.Period: Hashable {} + +extension BackendProductInfo.Period: CustomStringConvertible { + package init(rawValue: String) { + self = switch rawValue { + case "weekly": .weekly + case "monthly": .monthly + case "two_months": .twoMonths + case "trimonthly": .trimonthly + case "semiannual": .semiannual + case "annual": .annual + case "lifetime": .lifetime + case "consumable": .consumable + case "nonsubscriptions": .nonSubscriptions + case "uncategorised": .uncategorised(nil) + default: .uncategorised(rawValue) + } + } + + package var rawValue: String { + switch self { + case .weekly: "weekly" + case .monthly: "monthly" + case .twoMonths: "two_months" + case .trimonthly: "trimonthly" + case .semiannual: "semiannual" + case .annual: "annual" + case .lifetime: "lifetime" + case .consumable: "consumable" + case .nonSubscriptions: "nonsubscriptions" + case .uncategorised(let value): value ?? "uncategorised" + } + } + + package var description: String { rawValue } +} + +extension BackendProductInfo.Period: Codable { + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(rawValue: container.decode(String.self)) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Sources/StoreKit/Entities/BackendProductInfo.swift b/Sources/StoreKit/Entities/BackendProductInfo.swift new file mode 100644 index 000000000..6f8a1cacb --- /dev/null +++ b/Sources/StoreKit/Entities/BackendProductInfo.swift @@ -0,0 +1,36 @@ +// +// BackendProductInfo.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 11.08.2025. +// + +import Foundation + +package struct BackendProductInfo: Sendable { + let vendorId: String + let accessLevelId: String + let period: Period + + package init(vendorId: String, accessLevelId: String, period: Period) { + self.vendorId = vendorId + self.accessLevelId = accessLevelId + self.period = period + } +} + +extension BackendProductInfo: Hashable {} + +extension BackendProductInfo: CustomStringConvertible { + package var description: String { + "(vendorId: \(vendorId), accessLevelId: \(accessLevelId), period: \(period))" + } +} + +extension BackendProductInfo: Codable { + enum CodingKeys: String, CodingKey { + case vendorId = "vendor_product_id" + case accessLevelId = "access_level_id" + case period = "product_type" + } +} diff --git a/Sources/StoreKit/Entities/PurchasedTransaction.OfferType.swift b/Sources/StoreKit/Entities/PurchasedTransaction.OfferType.swift deleted file mode 100644 index 167638c4a..000000000 --- a/Sources/StoreKit/Entities/PurchasedTransaction.OfferType.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// PurchasedTransaction.OfferType.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 29.01.2024. -// - -import Foundation -import StoreKit - -extension PurchasedTransaction { - enum OfferType: Int { - case unknown = 0 - case introductory = 1 - case promotional = 2 - case code = 3 - case winBack = 4 - } -} - -extension PurchasedTransaction.OfferType: Encodable { - private enum CodingValues: String { - case introductory - case promotional - case code - case winBack = "win_back" - case unknown - } - - func encode(to encoder: Encoder) throws { - let value: CodingValues = - switch self { - case .introductory: .introductory - case .promotional: .promotional - case .code: .code - case .winBack: .winBack - case .unknown: .unknown - } - - var container = encoder.singleValueContainer() - try container.encode(value.rawValue) - } -} diff --git a/Sources/StoreKit/Entities/PurchasedTransaction.SubscriptionOffer.swift b/Sources/StoreKit/Entities/PurchasedTransaction.SubscriptionOffer.swift deleted file mode 100644 index f9177da18..000000000 --- a/Sources/StoreKit/Entities/PurchasedTransaction.SubscriptionOffer.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// PurchasedTransaction.SubscriptionOffer.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 08.09.2022. -// - -import Foundation - -extension PurchasedTransaction { - struct SubscriptionOffer: Sendable { - let id: String? - let period: AdaptySubscriptionPeriod? - let paymentMode: AdaptySubscriptionOffer.PaymentMode - let offerType: PurchasedTransaction.OfferType - let price: Decimal? - - init( - id: String, - offerType: PurchasedTransaction.OfferType - ) { - self.id = id - period = nil - paymentMode = .unknown - self.offerType = offerType - price = nil - } - - init( - id: String?, - period: AdaptySubscriptionPeriod?, - paymentMode: AdaptySubscriptionOffer.PaymentMode, - offerType: PurchasedTransaction.OfferType, - price: Decimal? - ) { - self.id = id - self.period = period - self.paymentMode = paymentMode - self.offerType = offerType - self.price = price - } - } -} - -extension PurchasedTransaction.SubscriptionOffer: Encodable { - enum BackendCodingKeys: String, CodingKey { - case periodUnit = "period_unit" - case periodNumberOfUnits = "number_of_units" - case paymentMode = "type" - case offerType = "category" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: BackendCodingKeys.self) - try container.encode(paymentMode, forKey: .paymentMode) - try container.encodeIfPresent(period?.unit, forKey: .periodUnit) - try container.encodeIfPresent(period?.numberOfUnits, forKey: .periodNumberOfUnits) - try container.encode(offerType, forKey: .offerType) - } -} diff --git a/Sources/StoreKit/Entities/PurchasedTransaction.swift b/Sources/StoreKit/Entities/PurchasedTransaction.swift deleted file mode 100644 index 90fc69238..000000000 --- a/Sources/StoreKit/Entities/PurchasedTransaction.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// PurchasedTransaction.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 08.09.2022. -// - -import Foundation - -struct PurchasedTransaction: Sendable { - let transactionId: String - let originalTransactionId: String - let vendorProductId: String - let paywallVariationId: String? - let persistentPaywallVariationId: String? - let persistentOnboardingVariationId: String? - let price: Decimal? - let priceLocale: String? - let storeCountry: String? - let subscriptionOffer: SubscriptionOffer? - let environment: String? -} - -extension PurchasedTransaction: Encodable { - enum CodingKeys: String, CodingKey { - case transactionId = "transaction_id" - case originalTransactionId = "original_transaction_id" - case vendorProductId = "vendor_product_id" - case paywallVariationId = "variation_id" - case persistentPaywallVariationId = "variation_id_persistent" - case persistentOnboardingVariationId = "onboarding_variation_id" - case originalPrice = "original_price" - case discountPrice = "discount_price" - case priceLocale = "price_locale" - case storeCountry = "store_country" - case promotionalOfferId = "promotional_offer_id" - case subscriptionOffer = "offer" - case environment - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(transactionId, forKey: .transactionId) - try container.encode(originalTransactionId, forKey: .originalTransactionId) - try container.encode(vendorProductId, forKey: .vendorProductId) - try container.encodeIfPresent(paywallVariationId, forKey: .paywallVariationId) - try container.encodeIfPresent(persistentPaywallVariationId, forKey: .persistentPaywallVariationId) - try container.encodeIfPresent(persistentOnboardingVariationId, forKey: .persistentOnboardingVariationId) - try container.encodeIfPresent(price, forKey: .originalPrice) - try container.encodeIfPresent(subscriptionOffer?.price, forKey: .discountPrice) - try container.encodeIfPresent(priceLocale, forKey: .priceLocale) - try container.encodeIfPresent(storeCountry, forKey: .storeCountry) - try container.encodeIfPresent(subscriptionOffer?.id, forKey: .promotionalOfferId) - try container.encodeIfPresent(subscriptionOffer, forKey: .subscriptionOffer) - try container.encodeIfPresent(environment, forKey: .environment) - } -} diff --git a/Sources/StoreKit/Entities/SKTransaction.swift b/Sources/StoreKit/Entities/SKTransaction.swift new file mode 100644 index 000000000..5aade11de --- /dev/null +++ b/Sources/StoreKit/Entities/SKTransaction.swift @@ -0,0 +1,23 @@ +// +// SKTransaction.swift +// Adapty +// +// Created by Aleksei Valiano on 06.08.2025. +// + +protocol SKTransaction: Sendable { + var unfIdentifier: String { get } + var unfOriginalIdentifier: String { get } + var unfProductId: String { get } + var unfOfferId: String? { get } + var unfEnvironment: String { get } + + func logParams(other: EventParameters?) -> EventParameters +} + +extension SK1TransactionWithIdentifier: SKTransaction { + var unfEnvironment: String { "unknown" } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2Transaction: SKTransaction {} diff --git a/Sources/StoreKit/Entities/Untitled.swift b/Sources/StoreKit/Entities/Untitled.swift deleted file mode 100644 index e69de29bb..000000000 diff --git a/Sources/StoreKit/PurchaseValidator.swift b/Sources/StoreKit/PurchaseValidator.swift deleted file mode 100644 index b8b2f29a1..000000000 --- a/Sources/StoreKit/PurchaseValidator.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// PurchaseValidator.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 04.10.2024 -// - -import Foundation - -protocol PurchaseValidator: AnyObject, Sendable { - func validatePurchase( - profileId: String?, - transaction: PurchasedTransaction, - reason: Adapty.ValidatePurchaseReason - ) async throws(AdaptyError) -> VH - - func signSubscriptionOffer( - profileId: String, - vendorProductId: String, - offerId: String - ) async throws(AdaptyError) -> AdaptySubscriptionOffer.Signature -} - -extension Adapty: PurchaseValidator { - enum ValidatePurchaseReason: Sendable, Hashable { - case setVariation - case observing - case purchasing - case sk2Updates - } - - func reportTransaction( - profileId: String?, - transactionId: String, - variationId: String? - ) async throws(AdaptyError) -> VH { - do { - let response = try await httpSession.reportTransaction( - profileId: profileId ?? profileStorage.profileId, - transactionId: transactionId, - variationId: variationId - ) - saveResponse(response, syncedTransaction: true) - return response - } catch { - throw error.asAdaptyError - } - } - - func validatePurchase( - profileId: String?, - transaction: PurchasedTransaction, - reason: Adapty.ValidatePurchaseReason - ) async throws(AdaptyError) -> VH { - do { - let response = try await httpSession.validateTransaction( - profileId: profileId ?? profileStorage.profileId, - purchasedTransaction: transaction, - reason: reason - ) - saveResponse(response, syncedTransaction: true) - return response - } catch { - throw error.asAdaptyError - } - } - - func signSubscriptionOffer( - profileId: String, - vendorProductId: String, - offerId: String - ) async throws(AdaptyError) -> AdaptySubscriptionOffer.Signature { - do { - let response = try await httpSession.signSubscriptionOffer( - profileId: profileId, - vendorProductId: vendorProductId, - offerId: offerId - ) - return response - } catch { - throw error.asAdaptyError - } - } -} diff --git a/Sources/StoreKit/Purchased/AdaptyProfile+SK2Transactions.swift b/Sources/StoreKit/Purchased/AdaptyProfile+SK2Transactions.swift new file mode 100644 index 000000000..f06c08ebd --- /dev/null +++ b/Sources/StoreKit/Purchased/AdaptyProfile+SK2Transactions.swift @@ -0,0 +1,63 @@ +// +// AdaptyProfile+SK2Transactions.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 10.08.2025. +// + +import StoreKit + +private let log = Log.sk2ProductManager + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension AdaptyProfile { + func added(transactions: [SK2Transaction], productManager: SK2ProductsManager) async -> AdaptyProfile { + guard !transactions.isEmpty else { return self } + var accessLevels = [String: AdaptyProfile.AccessLevel]() + let products = try? await productManager.fetchSK2Products( + ids: Set(transactions.map { $0.unfProductId }), + fetchPolicy: .returnCacheDataElseLoad + ) + for transaction in transactions { + let sk2Product = products?.first(where: { $0.id == transaction.unfProductId }) + guard let productInfo = await productManager.getProductInfo(vendorId: transaction.unfProductId) else { + log.warn("Not found product info (productVendorId:\(transaction.unfProductId))") + continue + } + guard let accessLevel = await AdaptyProfile.AccessLevel( + sk2Transaction: transaction, + sk2Product: sk2Product, + backendPeriod: productInfo.period + ) else { continue } + accessLevels[productInfo.accessLevelId] = accessLevel + } + guard !accessLevels.isEmpty else { return self } + return merge(accessLevels: accessLevels) + } + + private func merge(accessLevels: [String: AccessLevel]) -> Self { + var profile = self + var resultAcessLevels = profile.accessLevels + for (accessLevelId, newValue) in accessLevels { + var needSetNewValue = true + if let oldValue = resultAcessLevels[accessLevelId] { + switch (oldValue.expiresAt, newValue.expiresAt) { + case (.some, nil): + needSetNewValue = false + case (nil, nil): + needSetNewValue = !oldValue.isActive && newValue.isActive + case (nil, .some): + needSetNewValue = true + case let (oldExpiresAt?, newExpiresAt?): + needSetNewValue = oldExpiresAt < newExpiresAt + } + } + + if needSetNewValue { + resultAcessLevels[accessLevelId] = newValue + } + } + profile.accessLevels = resultAcessLevels + return profile + } +} diff --git a/Sources/StoreKit/Purchased/AdaptyProfile.AccessLevel+Create.swift b/Sources/StoreKit/Purchased/AdaptyProfile.AccessLevel+Create.swift new file mode 100644 index 000000000..1c7b7253e --- /dev/null +++ b/Sources/StoreKit/Purchased/AdaptyProfile.AccessLevel+Create.swift @@ -0,0 +1,140 @@ +// +// AdaptyProfile.AccessLevel+Create.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 01.08.2025. +// + +import StoreKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension AdaptyProfile.AccessLevel { + init?( + sk2Transaction: SK2Transaction, + sk2Product: SK2Product?, + backendPeriod: BackendProductInfo.Period?, + now: Date = Date() + ) async { + let productType = sk2Transaction.productType + let activatedAt = sk2Transaction.originalPurchaseDate + let isLifetime = backendPeriod == .lifetime + var isRefund = sk2Transaction.revocationDate != nil + + let offer = PurchasedSubscriptionOfferInfo( + sk2Transaction: sk2Transaction, + sk2Product: sk2Product + ) + let expiresAt: Date? + + var subscriprionNotEntitled = false + var subscriptionWillRenew = false + var subscriptionRenewedAt: Date? + var subscriptionInGracePeriod = false + var subscriptionUnsubscribedAt: Date? + var subscriptionExpirationReason: Product.SubscriptionInfo.RenewalInfo.ExpirationReason? + var subscriptionGracePeriodExpiredAt: Date? + + switch productType { + case .autoRenewable: + if let subscriptionStatus = await sk2Transaction.subscriptionStatus { + let state = subscriptionStatus.state + + if let renewalInfo = try? subscriptionStatus.renewalInfo.payloadValue { + subscriptionWillRenew = renewalInfo.willAutoRenew + subscriptionExpirationReason = renewalInfo.expirationReason + subscriptionGracePeriodExpiredAt = renewalInfo.gracePeriodExpirationDate + + if renewalInfo.expirationReason == .billingError { + if renewalInfo.isInBillingRetry { + subscriprionNotEntitled = renewalInfo.gracePeriodExpirationDate == nil + } else { + subscriprionNotEntitled = true + } + } + } + subscriptionInGracePeriod = state == .inGracePeriod + isRefund = state == .revoked || isRefund + } + + subscriptionRenewedAt = sk2Transaction.purchaseDate == activatedAt ? nil : sk2Transaction.purchaseDate + + expiresAt = sk2Transaction.revocationDate + ?? subscriptionGracePeriodExpiredAt + ?? sk2Transaction.expirationDate + + if !subscriptionWillRenew, let expiresAt { + subscriptionUnsubscribedAt = min(now, expiresAt) + } + + default: + expiresAt = sk2Transaction.revocationDate + ?? sk2Transaction.expirationDate + ?? backendPeriod?.expiresAt(startedAt: sk2Transaction.purchaseDate) + } + + guard expiresAt != nil || isLifetime else { return nil } + + self.init( + id: UUID().uuidString, + isActive: { + if subscriprionNotEntitled { return false } + if isRefund { return false } + if isLifetime { return true } + if let expiresAt, now > expiresAt { return false } + return true + }(), + vendorProductId: sk2Transaction.unfProductId, + store: "app_store", + activatedAt: activatedAt, + renewedAt: subscriptionRenewedAt, + expiresAt: expiresAt, + isLifetime: isLifetime, + activeIntroductoryOfferType: offer?.activeIntroductoryOfferType, + activePromotionalOfferType: offer?.activePromotionalOfferType, + activePromotionalOfferId: offer?.activePromotionalOfferId, + offerId: nil, // Android Only + willRenew: subscriptionWillRenew, + isInGracePeriod: subscriptionInGracePeriod, + unsubscribedAt: subscriptionUnsubscribedAt, + billingIssueDetectedAt: nil, // TODO: need calculate + startsAt: nil, // Backend Only + cancellationReason: subscriptionExpirationReason?.asString(isRefund), + isRefund: isRefund + ) + } +} + +private extension PurchasedSubscriptionOfferInfo { + var activeIntroductoryOfferType: String? { + (offerType == .introductory) ? paymentMode.encodedValue : nil + } + + var activePromotionalOfferType: String? { + (offerType == .promotional) ? paymentMode.encodedValue : nil + } + + var activePromotionalOfferId: String? { + (offerType == .promotional) ? id : nil + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +private extension Product.SubscriptionInfo.RenewalInfo.ExpirationReason { + func asString(_ isRefund: Bool) -> String { + guard !isRefund else { + return "refund" + } + return switch self { + case .autoRenewDisabled: + "voluntarily_cancelled" + case .billingError: + "billing_error" + case .didNotConsentToPriceIncrease: + "price_increase" + case .productUnavailable: + "product_was_not_available" + default: + "unknown" + } + } +} diff --git a/Sources/StoreKit/Purchased/AdaptyProfile.NonSubscription+Create.swift b/Sources/StoreKit/Purchased/AdaptyProfile.NonSubscription+Create.swift new file mode 100644 index 000000000..78c766f81 --- /dev/null +++ b/Sources/StoreKit/Purchased/AdaptyProfile.NonSubscription+Create.swift @@ -0,0 +1,26 @@ +// +// AdaptyProfile.NonSubscription+Create.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 01.08.2025. +// + +//import StoreKit +// +//@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +//extension AdaptyProfile.NonSubscription { +// init( +// sk2Transaction: SK2Transaction +// ) { +// self.init( +// purchaseId: UUID().uuidString, +// store: "app_store", +// vendorProductId: sk2Transaction.unfProductID, +// vendorTransactionId: sk2Transaction.unfIdentifier, +// purchasedAt: sk2Transaction.purchaseDate, +// isSandbox: sk2Transaction.isSandbox, +// isRefund: sk2Transaction.revocationDate != nil, +// isConsumable: sk2Transaction.productType == .consumable +// ) +// } +//} diff --git a/Sources/StoreKit/Purchased/AdaptyProfile.Subscription+Create.swift b/Sources/StoreKit/Purchased/AdaptyProfile.Subscription+Create.swift new file mode 100644 index 000000000..bef4d4021 --- /dev/null +++ b/Sources/StoreKit/Purchased/AdaptyProfile.Subscription+Create.swift @@ -0,0 +1,43 @@ +// +// AdaptyProfile.Subscription+Create.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 01.08.2025. +// + +//import StoreKit +// +//@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +//extension AdaptyProfile.Subscription { +// init( +// sk2Transaction: SK2Transaction, +// sk2Product: SK2Product?, +// productInfo: BackendProductInfo.Period?, +// now: Date = Date() +// ) { +// let isLifetime = backendPeriod == .lifetime +// +// self.init( +// store: "app_store", +// vendorProductId: sk2Transaction.unfProductID, +// vendorTransactionId: sk2Transaction.unfIdentifier, +// vendorOriginalTransactionId: sk2Transaction.unfOriginalIdentifier, +// isActive: <#T##Bool#>, +// isLifetime: isLifetime, +// activatedAt: <#T##Date#>, +// renewedAt: <#T##Date?#>, +// expiresAt: <#T##Date?#>, +// startsAt: nil, +// unsubscribedAt: <#T##Date?#>, +// billingIssueDetectedAt: <#T##Date?#>, +// isInGracePeriod: <#T##Bool#>, +// isSandbox: sk2Transaction.isSandbox, +// isRefund: <#T##Bool#>, +// willRenew: <#T##Bool#>, +// activeIntroductoryOfferType: <#T##String?#>, +// activePromotionalOfferType: <#T##String?#>, +// activePromotionalOfferId: <#T##String?#>, +// offerId: <#T##String?#>, +// cancellationReason: <#T##String?#>) +// } +//} diff --git a/Sources/StoreKit/Purchased/PurchasePayload.swift b/Sources/StoreKit/Purchased/PurchasePayload.swift new file mode 100644 index 000000000..448db5e82 --- /dev/null +++ b/Sources/StoreKit/Purchased/PurchasePayload.swift @@ -0,0 +1,47 @@ +// +// PurchasePayload.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 08.09.2022. +// + +import Foundation + +struct PurchasePayload: Sendable, Hashable { + let userId: AdaptyUserId + let paywallVariationId: String? + let persistentPaywallVariationId: String? + let persistentOnboardingVariationId: String? + + init( + userId: AdaptyUserId, + paywallVariationId: String? = nil, + persistentPaywallVariationId: String? = nil, + persistentOnboardingVariationId: String? = nil + ) { + self.userId = userId + self.paywallVariationId = paywallVariationId + self.persistentPaywallVariationId = persistentPaywallVariationId + self.persistentOnboardingVariationId = persistentOnboardingVariationId + } +} + +extension PurchasePayload { + var logParams: EventParameters { + [ + "user_id": userId, + "variation_id": paywallVariationId, + "persited_variation_id": persistentPaywallVariationId, + "onboarding_variation_id": persistentOnboardingVariationId + ].removeNil + } +} + +extension PurchasePayload: Codable { + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case paywallVariationId = "paywall_variation_id" + case persistentPaywallVariationId = "persistent_paywall_variation_id" + case persistentOnboardingVariationId = "onboarding_variation_id" + } +} diff --git a/Sources/StoreKit/Purchased/PurchasedSubscriptionOfferInfo.swift b/Sources/StoreKit/Purchased/PurchasedSubscriptionOfferInfo.swift new file mode 100644 index 000000000..0de97e242 --- /dev/null +++ b/Sources/StoreKit/Purchased/PurchasedSubscriptionOfferInfo.swift @@ -0,0 +1,220 @@ +// +// PurchasedSubscriptionOfferInfo.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 08.09.2022. +// + +import Foundation + +struct PurchasedSubscriptionOfferInfo: Sendable { + let id: String? + let period: AdaptySubscriptionPeriod? + let paymentMode: AdaptySubscriptionOffer.PaymentMode + let offerType: AdaptySubscriptionOfferType + let price: Decimal? + + fileprivate init( + identifier: AdaptySubscriptionOffer.Identifier, + period: AdaptySubscriptionPeriod? = nil, + paymentMode: AdaptySubscriptionOffer.PaymentMode = .unknown, + price: Decimal? = nil + ) { + self.id = identifier.offerId + self.period = period + self.paymentMode = paymentMode + self.offerType = identifier.offerType + self.price = price + } +} + +extension PurchasedSubscriptionOfferInfo { + init?( + transaction: SKTransaction, + product: AdaptyProduct? + ) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), + let sk2Transaction = transaction as? SK2Transaction + { + self.init(sk2Transaction: sk2Transaction, product: product) + } else if let sk1Transaction = transaction as? SK1TransactionWithIdentifier { + self.init(sk1Transaction: sk1Transaction, product: product) + } else { + return nil + } + } + + private init?( + sk1Transaction: SK1TransactionWithIdentifier, + product: AdaptyProduct? + ) { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), + let sk2Product = product?.sk2Product + { + self.init(sk1Transaction: sk1Transaction, sk2Product: sk2Product) + } else if let sk1Product = product?.sk1Product { + self.init(sk1Transaction: sk1Transaction, sk1Product: sk1Product) + } else { + self.init(sk1Transaction: sk1Transaction, sk1Product: nil) + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + private init?( + sk2Transaction: SK2Transaction, + product: AdaptyProduct? + ) { + if let sk2Product = product?.sk2Product { + self.init(sk2Transaction: sk2Transaction, sk2Product: sk2Product) + } else if let sk1Product = product?.sk1Product { + self.init(sk2Transaction: sk2Transaction, sk1Product: sk1Product) + } else { + self.init(sk2Transaction: sk2Transaction, sk1Product: nil) + } + } + + private init?( + sk1Transaction: SK1TransactionWithIdentifier, + sk1Product: SK1Product? + ) { + let offerIdentifier: AdaptySubscriptionOffer.Identifier + let sk1ProductOffer: SK1Product.SubscriptionOffer + + if let offerId = sk1Transaction.unfOfferId { + offerIdentifier = .promotional(offerId) + guard let value = sk1Product?.sk1ProductSubscriptionOffer(by: offerIdentifier) else { + self.init(identifier: offerIdentifier) + return + } + sk1ProductOffer = value + } else { + offerIdentifier = .introductory + guard let value = sk1Product?.sk1ProductSubscriptionOffer(by: offerIdentifier) else { + return nil + } + sk1ProductOffer = value + } + + self.init( + identifier: offerIdentifier, + period: sk1ProductOffer.subscriptionPeriod.asAdaptySubscriptionPeriod, + paymentMode: sk1ProductOffer.paymentMode.asPaymentMode, + price: sk1ProductOffer.price.decimalValue + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + private init?( + sk1Transaction: SK1TransactionWithIdentifier, + sk2Product: SK2Product? + ) { + let offerIdentifier: AdaptySubscriptionOffer.Identifier + let sk2ProductOffer: SK2Product.SubscriptionOffer + + if let offerId = sk1Transaction.unfOfferId { + offerIdentifier = .promotional(offerId) + guard let value = sk2Product?.sk2ProductSubscriptionOffer(by: offerIdentifier) else { + self.init(identifier: offerIdentifier) + return + } + sk2ProductOffer = value + } else { + offerIdentifier = .introductory + guard let value = sk2Product?.sk2ProductSubscriptionOffer(by: offerIdentifier) else { + return nil + } + sk2ProductOffer = value + } + + self.init( + identifier: offerIdentifier, + period: sk2ProductOffer.period.asAdaptySubscriptionPeriod, + paymentMode: sk2ProductOffer.paymentMode.asPaymentMode, + price: sk2ProductOffer.price + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + private init?( + sk2Transaction: SK2Transaction, + sk1Product: SK1Product? + ) { + guard let offerIdentifier = sk2Transaction.subscriptionOfferIdentifier else { return nil } + let sk1ProductOffer = sk1Product?.sk1ProductSubscriptionOffer(by: offerIdentifier) + self.init( + identifier: offerIdentifier, + period: sk1ProductOffer?.subscriptionPeriod.asAdaptySubscriptionPeriod, + paymentMode: sk1ProductOffer?.paymentMode.asPaymentMode ?? .unknown, + price: sk1ProductOffer?.price.decimalValue, + for: sk2Transaction + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init?( + sk2Transaction: SK2Transaction, + sk2Product: SK2Product? + ) { + guard let offerIdentifier = sk2Transaction.subscriptionOfferIdentifier else { return nil } + let sk2ProductOffer = sk2Product?.sk2ProductSubscriptionOffer(by: offerIdentifier) + self.init( + identifier: offerIdentifier, + period: sk2ProductOffer?.period.asAdaptySubscriptionPeriod, + paymentMode: sk2ProductOffer?.paymentMode.asPaymentMode ?? .unknown, + price: sk2ProductOffer?.price, + for: sk2Transaction + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + private init?( + identifier: AdaptySubscriptionOffer.Identifier, + period: AdaptySubscriptionPeriod?, + paymentMode: AdaptySubscriptionOffer.PaymentMode, + price: Decimal?, + for sk2Transaction: SK2Transaction + ) { + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *), + let offer = sk2Transaction.offer + { + self.init( + identifier: identifier, + productOfferPeriod: period, + price: price, + for: offer + ) + } else { + self.init( + identifier: identifier, + period: period, + paymentMode: paymentMode, + price: price + ) + } + } + + @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) + private init?( + identifier: AdaptySubscriptionOffer.Identifier, + productOfferPeriod: AdaptySubscriptionPeriod?, + price: Decimal?, + for sk2TransactionOffer: SK2Transaction.Offer + ) { + var period: AdaptySubscriptionPeriod? + + #if compiler(>=6.1) + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + period = sk2TransactionOffer.period?.asAdaptySubscriptionPeriod + } + #else + period = productOfferPeriod + #endif + + self.init( + identifier: sk2TransactionOffer.subscriptionOfferIdentifier ?? identifier, + period: period ?? productOfferPeriod, + paymentMode: sk2TransactionOffer.paymentMode?.asPaymentMode ?? .unknown, + price: price + ) + } +} diff --git a/Sources/StoreKit/Purchased/PurchasedTransactionInfo.swift b/Sources/StoreKit/Purchased/PurchasedTransactionInfo.swift new file mode 100644 index 000000000..054f5a22d --- /dev/null +++ b/Sources/StoreKit/Purchased/PurchasedTransactionInfo.swift @@ -0,0 +1,35 @@ +// +// PurchasedTransactionInfo.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 08.09.2022. +// + +import Foundation + +struct PurchasedTransactionInfo: Sendable { + let transactionId: String + let originalTransactionId: String + let vendorProductId: String + let price: Decimal? + let priceLocale: String? + let storeCountry: String? + let subscriptionOffer: PurchasedSubscriptionOfferInfo? + let environment: String + + init( + product: AdaptyProduct?, + transaction: SKTransaction + ) { + self.transactionId = transaction.unfIdentifier + self.originalTransactionId = transaction.unfOriginalIdentifier + self.vendorProductId = transaction.unfProductId + self.price = product?.price + self.priceLocale = product?.priceLocale.unfCurrencyCode + self.storeCountry = product?.priceLocale.unfRegionCode + self.subscriptionOffer = .init(transaction: transaction, product: product) + self.environment = transaction.unfEnvironment + } +} + + diff --git a/Sources/StoreKit/SK1PaywallProducts.swift b/Sources/StoreKit/SK1PaywallProducts.swift index a566854e2..1070a9984 100644 --- a/Sources/StoreKit/SK1PaywallProducts.swift +++ b/Sources/StoreKit/SK1PaywallProducts.swift @@ -21,13 +21,14 @@ extension Adapty { .compactMap { sk1Product in let vendorId = sk1Product.productIdentifier - guard let reference = paywall.products.first(where: { $0.vendorId == vendorId }) else { + guard let reference = paywall.products.first(where: { $0.productInfo.vendorId == vendorId }) else { return nil } return AdaptySK1PaywallProductWithoutDeterminingOffer( skProduct: sk1Product, adaptyProductId: reference.adaptyProductId, + productInfo: reference.productInfo, paywallProductIndex: reference.paywallProductIndex, variationId: paywall.variationId, paywallABTestName: paywall.placement.abTestName, @@ -38,8 +39,8 @@ extension Adapty { } func getSK1PaywallProduct( - vendorProductId: String, adaptyProductId: String, + productInfo: BackendProductInfo, paywallProductIndex: Int, subscriptionOfferIdentifier: AdaptySubscriptionOffer.Identifier?, variationId: String, @@ -48,14 +49,14 @@ extension Adapty { productsManager: SK1ProductsManager, webPaywallBaseUrl: URL? ) async throws(AdaptyError) -> AdaptySK1PaywallProduct { - let sk1Product = try await productsManager.fetchSK1Product(id: vendorProductId, fetchPolicy: .returnCacheDataElseLoad) + let sk1Product = try await productsManager.fetchSK1Product(id: productInfo.vendorId, fetchPolicy: .returnCacheDataElseLoad) let subscriptionOffer: AdaptySubscriptionOffer? = if let subscriptionOfferIdentifier { if let offer = sk1Product.subscriptionOffer(by: subscriptionOfferIdentifier) { offer } else { - throw StoreKitManagerError.invalidOffer("StoreKit1 product don't have offer id: `\(subscriptionOfferIdentifier.identifier ?? "nil")` with type:\(subscriptionOfferIdentifier.asOfferType.rawValue) ").asAdaptyError + throw StoreKitManagerError.invalidOffer("StoreKit1 product don't have offer id: `\(subscriptionOfferIdentifier.offerId ?? "nil")` with type:\(subscriptionOfferIdentifier.offerType.rawValue) ").asAdaptyError } } else { nil @@ -64,6 +65,7 @@ extension Adapty { return AdaptySK1PaywallProduct( skProduct: sk1Product, adaptyProductId: adaptyProductId, + productInfo: productInfo, paywallProductIndex: paywallProductIndex, subscriptionOffer: subscriptionOffer, variationId: variationId, @@ -91,7 +93,7 @@ extension Adapty { var products: [ProductTuple] = sk1Products.compactMap { sk1Product in let vendorId = sk1Product.productIdentifier - guard let reference = paywall.products.first(where: { $0.vendorId == vendorId }) else { + guard let reference = paywall.products.first(where: { $0.productInfo.vendorId == vendorId }) else { return nil } @@ -112,12 +114,13 @@ extension Adapty { return $0.product.productIdentifier } - if !vendorProductIds.isEmpty { + if vendorProductIds.isNotEmpty { let introductoryOfferEligibility = await getIntroductoryOfferEligibility(vendorProductIds: vendorProductIds) products = products.map { guard !$0.determinedOffer else { return $0 } return if let introductoryOffer = $0.product.subscriptionOffer(by: .introductory), - introductoryOfferEligibility.contains($0.product.productIdentifier) { + introductoryOfferEligibility.contains($0.product.productIdentifier) + { (product: $0.product, reference: $0.reference, offer: introductoryOffer, determinedOffer: true) } else { (product: $0.product, reference: $0.reference, offer: nil, determinedOffer: true) @@ -129,6 +132,7 @@ extension Adapty { AdaptySK1PaywallProduct( skProduct: $0.product, adaptyProductId: $0.reference.adaptyProductId, + productInfo: $0.reference.productInfo, paywallProductIndex: $0.reference.paywallProductIndex, subscriptionOffer: $0.offer, variationId: paywall.variationId, @@ -148,41 +152,39 @@ extension Adapty { return offer } - private func getProfileState() -> (profileId: String, ineligibleProductIds: Set)? { + private func getProfileState() -> (userId: AdaptyUserId, ineligibleProductIds: Set)? { guard let manager = profileManager else { return nil } return ( - manager.profileId, + manager.userId, manager.backendIntroductoryOfferEligibilityStorage.getIneligibleProductIds() ) } private func getIntroductoryOfferEligibility(vendorProductIds: [String]) async -> [String] { - guard let profileState = getProfileState() else { return [] } - let (profileId, ineligibleProductIds) = profileState + guard let (userId, ineligibleProductIds) = getProfileState() else { return [] } let vendorProductIds = vendorProductIds.filter { !ineligibleProductIds.contains($0) } - guard !vendorProductIds.isEmpty else { return [] } + guard vendorProductIds.isNotEmpty else { return [] } - if !profileStorage.syncedTransactions { - do { - try await syncTransactions(for: profileId) - } catch { - return [] - } + do { + try await syncTransactionHistory(for: userId) + } catch { + return [] } - let lastResponse = try? profileManager(with: profileId)?.backendIntroductoryOfferEligibilityStorage.getLastResponse() + let lastResponse = try? profileManager(withProfileId: userId)? + .backendIntroductoryOfferEligibilityStorage + .getLastResponse() + do { let response = try await httpSession.fetchIntroductoryOfferEligibility( - profileId: profileId, + userId: userId, responseHash: lastResponse?.hash - ).flatValue() - - guard let response else { return lastResponse?.eligibleProductIds ?? [] } + ) - if let manager = try? profileManager(with: profileId) { + if let manager = try? profileManager(withProfileId: userId) { return manager.backendIntroductoryOfferEligibilityStorage.save(response) } else { return response.value.filter(\.value).map(\.vendorId) diff --git a/Sources/StoreKit/SK1ProductFetcher.swift b/Sources/StoreKit/SK1ProductFetcher.swift index b9b06c0f9..eb2b046e4 100644 --- a/Sources/StoreKit/SK1ProductFetcher.swift +++ b/Sources/StoreKit/SK1ProductFetcher.swift @@ -84,7 +84,7 @@ extension _SK1ProductFetcher: SKProductsRequestDelegate { } guard let self else { return } - if !response.invalidProductIdentifiers.isEmpty { + if response.invalidProductIdentifiers.isNotEmpty { log.warn("InvalidProductIdentifiers: \(response.invalidProductIdentifiers.joined(separator: ", "))") } diff --git a/Sources/StoreKit/SK1ProductsManager+PurchasedTransaction.swift b/Sources/StoreKit/SK1ProductsManager+PurchasedTransaction.swift deleted file mode 100644 index 6cce30604..000000000 --- a/Sources/StoreKit/SK1ProductsManager+PurchasedTransaction.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// SK1ProductsManager+PurchasedTransaction.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 06.10.2024 -// - -import Foundation - -private let log = Log.sk1ProductManager - -extension SK1ProductsManager { - func fillPurchasedTransaction( - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk1Transaction: SK1TransactionWithIdentifier - ) async -> PurchasedTransaction { - await PurchasedTransaction( - sk1Product: try? fetchSK1Product( - id: sk1Transaction.unfProductID, - fetchPolicy: .returnCacheDataElseLoad - ), - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk1Transaction: sk1Transaction - ) - } - - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - func fillPurchasedTransaction( - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk2Transaction: SK2Transaction - ) async -> PurchasedTransaction { - await PurchasedTransaction( - sk1Product: try? fetchSK1Product( - id: sk2Transaction.unfProductID, - fetchPolicy: .returnCacheDataElseLoad - ), - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk2Transaction: sk2Transaction - ) - } -} - -extension PurchasedTransaction { - init( - sk1Product: SK1Product?, - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk1Transaction: SK1TransactionWithIdentifier - ) { - let offer = SubscriptionOffer( - sk1Transaction: sk1Transaction, - sk1Product: sk1Product - ) - - self.init( - transactionId: sk1Transaction.unfIdentifier, - originalTransactionId: sk1Transaction.unfOriginalIdentifier, - vendorProductId: sk1Transaction.unfProductID, - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - price: sk1Product?.price.decimalValue, - priceLocale: sk1Product?.priceLocale.unfCurrencyCode, - storeCountry: sk1Product?.priceLocale.unfRegionCode, - subscriptionOffer: offer, - environment: nil - ) - } - - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - fileprivate init( - sk1Product: SK1Product?, - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk2Transaction: SK2Transaction - ) { - let offer: PurchasedTransaction.SubscriptionOffer? = { - if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { - return .init( - sk2TransactionOffer: sk2Transaction.offer, - sk1Product: sk1Product - ) - } - return .init( - sk2Transaction: sk2Transaction, - sk1Product: sk1Product - ) - }() - - self.init( - transactionId: sk2Transaction.unfIdentifier, - originalTransactionId: sk2Transaction.unfOriginalIdentifier, - vendorProductId: sk2Transaction.unfProductID, - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - price: sk1Product?.price.decimalValue, - priceLocale: sk1Product?.priceLocale.unfCurrencyCode, - storeCountry: sk1Product?.priceLocale.unfRegionCode, - subscriptionOffer: offer, - environment: sk2Transaction.unfEnvironment - ) - } -} - -private extension PurchasedTransaction.SubscriptionOffer { - init?( - sk1Transaction: SK1TransactionWithIdentifier, - sk1Product: SK1Product? - ) { - guard let offerId = sk1Transaction.unfOfferId else { - let sk1ProductOffer = sk1Product?.subscriptionOffer( - byType: .introductory - ) - - guard let sk1ProductOffer else { return nil } - - self.init( - id: nil, - period: sk1ProductOffer.subscriptionPeriod.asAdaptySubscriptionPeriod, - paymentMode: sk1ProductOffer.paymentMode.asPaymentMode, - offerType: .introductory, - price: sk1ProductOffer.price.decimalValue - ) - return - } - - let sk1ProductOffer = sk1Product?.subscriptionOffer( - byType: .promotional, - withId: offerId - ) - - if let sk1ProductOffer { - self.init( - id: sk1ProductOffer.identifier, - period: sk1ProductOffer.subscriptionPeriod.asAdaptySubscriptionPeriod, - paymentMode: sk1ProductOffer.paymentMode.asPaymentMode, - offerType: .promotional, - price: sk1ProductOffer.price.decimalValue - ) - } else { - self.init(id: offerId, offerType: .promotional) - } - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -private extension PurchasedTransaction.SubscriptionOffer { - init?( - sk2Transaction: SK2Transaction, - sk1Product: SK1Product? - ) { - guard let offerType = sk2Transaction.unfOfferType?.asPurchasedTransactionOfferType else { return nil } - - let sk1ProductOffer = sk1Product?.subscriptionOffer( - byType: offerType, - withId: sk2Transaction.unfOfferId - ) - - self.init( - id: sk2Transaction.unfOfferId, - period: sk1ProductOffer?.subscriptionPeriod.asAdaptySubscriptionPeriod, - paymentMode: sk1ProductOffer?.paymentMode.asPaymentMode ?? .unknown, - offerType: offerType, - price: sk1ProductOffer?.price.decimalValue - ) - } - - @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) - init?( - sk2TransactionOffer: SK2Transaction.Offer?, - sk1Product: SK1Product? - ) { - guard let sk2TransactionOffer else { return nil } - - let offerType = sk2TransactionOffer.type.asPurchasedTransactionOfferType - - let sk1ProductOffer = sk1Product?.subscriptionOffer( - byType: offerType, - withId: sk2TransactionOffer.id - ) - - self = .init( - id: sk2TransactionOffer.id, - period: sk1ProductOffer?.subscriptionPeriod.asAdaptySubscriptionPeriod, - paymentMode: sk2TransactionOffer.paymentMode?.asPaymentMode ?? .unknown, - offerType: offerType, - price: sk1ProductOffer?.price.decimalValue - ) - } -} - -private extension SK1Product { - func subscriptionOffer( - byType offerType: PurchasedTransaction.OfferType, - withId offerId: String? = nil - ) -> SK1Product.SubscriptionOffer? { - switch offerType { - case .introductory: - return introductoryPrice - case .promotional: - if let offerId { - return discounts.first(where: { $0.identifier == offerId }) - } - default: - return nil - } - return nil - } -} diff --git a/Sources/StoreKit/SK1ProductsManager.swift b/Sources/StoreKit/SK1ProductsManager.swift index f11de2f57..0c4931735 100644 --- a/Sources/StoreKit/SK1ProductsManager.swift +++ b/Sources/StoreKit/SK1ProductsManager.swift @@ -9,15 +9,15 @@ import Foundation private let log = Log.sk1ProductManager -actor SK1ProductsManager: StoreKitProductsManager { +actor SK1ProductsManager { private let apiKeyPrefix: String - private let storage: ProductVendorIdsStorage + private let storage: BackendProductInfoStorage private let session: Backend.MainExecutor private var products = [String: SK1Product]() private let sk1ProductsFetcher = SK1ProductFetcher() - init(apiKeyPrefix: String, session: Backend.MainExecutor, storage: ProductVendorIdsStorage) { + init(apiKeyPrefix: String, session: Backend.MainExecutor, storage: BackendProductInfoStorage) { self.apiKeyPrefix = apiKeyPrefix self.session = session self.storage = storage @@ -29,13 +29,17 @@ actor SK1ProductsManager: StoreKitProductsManager { fetchingAllProducts = false } + func storeProductInfo(productInfo: [BackendProductInfo]) async { + await storage.set(productInfo: productInfo) + } + private func fetchAllProducts() async { guard !fetchingAllProducts else { return } fetchingAllProducts = true do { - let response = try await session.fetchAllProductVendorIds(apiKeyPrefix: apiKeyPrefix) - await storage.set(productVendorIds: response) + let response = try await session.fetchProductInfo(apiKeyPrefix: apiKeyPrefix) + await storage.set(allProductInfo: response) } catch { guard !error.isCancelled else { return } Task.detached(priority: .utility) { [weak self] in @@ -82,11 +86,11 @@ extension SK1ProductsManager { func fetchSK1Products(ids productIds: Set, fetchPolicy: ProductsFetchPolicy = .default, retryCount: Int = 3) async throws(AdaptyError) -> [SK1Product] { - guard !productIds.isEmpty else { + guard productIds.isNotEmpty else { throw StoreKitManagerError.noProductIDsFound().asAdaptyError } - guard !productIds.isEmpty else { + guard productIds.isNotEmpty else { throw StoreKitManagerError.noProductIDsFound().asAdaptyError } @@ -99,7 +103,7 @@ extension SK1ProductsManager { let products = try await sk1ProductsFetcher.fetchProducts(ids: productIds, retryCount: retryCount) - guard !products.isEmpty else { + guard products.isNotEmpty else { throw StoreKitManagerError.noProductIDsFound().asAdaptyError } diff --git a/Sources/StoreKit/SK1QueueManager.swift b/Sources/StoreKit/SK1QueueManager.swift index 30a11c64f..3ab6ea045 100644 --- a/Sources/StoreKit/SK1QueueManager.swift +++ b/Sources/StoreKit/SK1QueueManager.swift @@ -10,35 +10,39 @@ import StoreKit private let log = Log.sk1QueueManager actor SK1QueueManager: Sendable { - private let purchaseValidator: PurchaseValidator + private let transactionSynchronizer: StoreKitTransactionSynchronizer + private let subscriptionOfferSigner: StoreKitSubscriptionOfferSigner private let productsManager: StoreKitProductsManager - private let storage: VariationIdStorage + private let storage: PurchasePayloadStorage private var makePurchasesCompletionHandlers = [String: [AdaptyResultCompletion]]() private var makePurchasesProduct = [String: SK1Product]() - fileprivate init(purchaseValidator: PurchaseValidator, productsManager: StoreKitProductsManager, storage: VariationIdStorage) { - self.purchaseValidator = purchaseValidator + fileprivate init( + transactionSynchronizer: StoreKitTransactionSynchronizer, + subscriptionOfferSigner: StoreKitSubscriptionOfferSigner, + productsManager: StoreKitProductsManager, + storage: PurchasePayloadStorage + ) { + self.transactionSynchronizer = transactionSynchronizer + self.subscriptionOfferSigner = subscriptionOfferSigner self.productsManager = productsManager self.storage = storage } func makePurchase( - profileId: String, + userId: AdaptyUserId, appAccountToken: UUID?, product: AdaptyPaywallProduct ) async throws(AdaptyError) -> AdaptyPurchaseResult { - guard SKPaymentQueue.canMakePayments() else { - throw AdaptyError.cantMakePayments() - } - - guard let sk1Product = product.sk1Product else { - throw AdaptyError.cantMakePayments() + guard SKPaymentQueue.canMakePayments(), + let product = product as? AdaptySK1PaywallProduct + else { + throw .cantMakePayments() } - let variationId = product.variationId - - var payment = SKMutablePayment(product: sk1Product) + let payment = SKMutablePayment(product: product.skProduct) +// payment.simulatesAskToBuyInSandbox = true if let appAccountToken { payment.applicationUsername = appAccountToken.uuidString.lowercased() @@ -51,40 +55,40 @@ actor SK1QueueManager: Sendable { case .winBack: throw StoreKitManagerError.invalidOffer("StoreKit1 Does not support winBackOffer purchase").asAdaptyError case let .promotional(offerId): - let response = try await purchaseValidator.signSubscriptionOffer( - profileId: profileId, - vendorProductId: product.vendorProductId, - offerId: offerId + let response = try await subscriptionOfferSigner.sign( + offerId: offerId, + subscriptionVendorId: product.vendorProductId, + for: userId ) payment.paymentDiscount = SK1PaymentDiscount( offerId: offerId, signature: response ) + case .code: + break } } - return try await addPayment( - payment, - for: sk1Product, - with: variationId - ) + await productsManager.storeProductInfo(productInfo: [product.productInfo]) + await storage.setPaywallVariationId(product.variationId, productId: product.vendorProductId, userId: userId) + return try await addPayment(payment, with: product.skProduct) } + @inlinable func makePurchase( product: AdaptyDeferredProduct ) async throws(AdaptyError) -> AdaptyPurchaseResult { - try await addPayment(product.payment, for: product.skProduct) + try await addPayment(product.payment, with: product.skProduct) } @inlinable func addPayment( _ payment: SKPayment, - for underlying: SK1Product, - with variationId: String? = nil + with sk1Product: SK1Product ) async throws(AdaptyError) -> AdaptyPurchaseResult { try await withCheckedThrowingContinuation_ { continuation in - addPayment(payment, for: underlying, with: variationId) { result in + addPayment(payment, with: sk1Product) { result in continuation.resume(with: result) } } @@ -92,13 +96,12 @@ actor SK1QueueManager: Sendable { private func addPayment( _ payment: SKPayment, - for underlying: SK1Product, - with variationId: String? = nil, + with sk1Product: SK1Product, _ completion: @escaping AdaptyResultCompletion ) { let productId = payment.productIdentifier - makePurchasesProduct[productId] = underlying + makePurchasesProduct[productId] = sk1Product if let handlers = makePurchasesCompletionHandlers[productId] { makePurchasesCompletionHandlers[productId] = handlers + [completion] @@ -108,17 +111,15 @@ actor SK1QueueManager: Sendable { makePurchasesCompletionHandlers[productId] = [completion] Task { - await storage.setPaywallVariationIds(variationId, for: productId) - await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( methodName: .addPayment, params: [ "product_id": productId, ] )) - - SKPaymentQueue.default().add(payment) } + + SKPaymentQueue.default().add(payment) } fileprivate func updatedTransactions(_ transactions: [SKPaymentTransaction]) async { @@ -133,78 +134,68 @@ actor SK1QueueManager: Sendable { switch sk1Transaction.transactionState { case .purchased: - guard let id = sk1Transaction.transactionIdentifier else { - log.error("received purchased transaction without identifier") - return + if let sk1Transaction = SK1TransactionWithIdentifier(sk1Transaction) { + await receivedPurchasedTransaction(sk1Transaction) + } else { + await receivedFailedTransaction(sk1Transaction, error: StoreKitManagerError.unknownTransactionId().asAdaptyError) } - await receivedPurchasedTransaction(SK1TransactionWithIdentifier(sk1Transaction, id: id)) case .failed: - SKPaymentQueue.default().finishTransaction(sk1Transaction) - await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( - methodName: .finishTransaction, - params: logParams - )) - log.verbose("finish failed transaction \(sk1Transaction)") - receivedFailedTransaction(sk1Transaction) + await receivedFailedTransaction(sk1Transaction, error: nil) + case .restored: SKPaymentQueue.default().finishTransaction(sk1Transaction) await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( methodName: .finishTransaction, params: logParams )) - log.verbose("finish restored transaction \(sk1Transaction)") + log.verbose("Finish restored transaction \(sk1Transaction)") + + case .deferred: + log.error("received deferred transaction \(sk1Transaction)") + default: - break + log.warn("received unknown state (\(sk1Transaction.transactionState)) for transaction \(sk1Transaction)") } } } private func receivedPurchasedTransaction(_ sk1Transaction: SK1TransactionWithIdentifier) async { - let productId = sk1Transaction.unfProductID - - let (paywallVariationId, persistentPaywallVariationId, persistentOnboardingVariationId) = await storage.getVariationIds(for: productId) - - let purchasedTransaction: PurchasedTransaction = - if let sk1Product = makePurchasesProduct[productId] { - PurchasedTransaction( - sk1Product: sk1Product, - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk1Transaction: sk1Transaction - ) - } else { - await productsManager.fillPurchasedTransaction( - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk1Transaction: sk1Transaction - ) - } - + let productId = sk1Transaction.unfProductId let result: AdaptyResult + + var productOrNil: AdaptyProduct? = makePurchasesProduct[productId]?.asAdaptyProduct + + if productOrNil == nil { + productOrNil = try? await productsManager.fetchProduct( + id: sk1Transaction.unfProductId, + fetchPolicy: .returnCacheDataElseLoad + ) + } + do { - let response = try await purchaseValidator.validatePurchase( - profileId: nil, - transaction: purchasedTransaction, - reason: .purchasing + let profile = try await transactionSynchronizer.validate( + .init( + product: productOrNil, + transaction: sk1Transaction + ), + payload: await storage.purchasePayload( + byProductId: productId, + orCreateFor: ProfileStorage.userId + ) ) - storage.removePaywallVariationIds(for: productId) + await storage.removePurchasePayload(forProductId: productId) makePurchasesProduct.removeValue(forKey: productId) - SKPaymentQueue.default().finishTransaction(sk1Transaction.underlay) - await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( methodName: .finishTransaction, params: sk1Transaction.logParams )) - log.info("finish purchased transaction \(sk1Transaction.underlay)") - - result = .success(.success(profile: response.value, transaction: sk1Transaction)) + log.info("Finish purchased transaction \(sk1Transaction) after sync") + result = .success(.success(profile: profile, transaction: sk1Transaction)) } catch { result = .failure(error) } @@ -212,16 +203,23 @@ actor SK1QueueManager: Sendable { callMakePurchasesCompletionHandlers(productId, result) } - private func receivedFailedTransaction(_ sk1Transaction: SK1Transaction) { - let productId = sk1Transaction.unfProductID - storage.removePaywallVariationIds(for: productId) + private func receivedFailedTransaction(_ sk1Transaction: SK1Transaction, error: AdaptyError? = nil) async { + let productId = sk1Transaction.unfProductId makePurchasesProduct.removeValue(forKey: productId) + await storage.removePurchasePayload(forProductId: productId) + SKPaymentQueue.default().finishTransaction(sk1Transaction) + await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( + methodName: .finishTransaction, + params: sk1Transaction.logParams + )) let result: AdaptyResult if (sk1Transaction.error as? SKError)?.isPurchaseCancelled ?? false { + log.info("Finish canceled transaction \(sk1Transaction) ") result = .success(.userCancelled) } else { - let error = StoreKitManagerError.productPurchaseFailed(sk1Transaction.error).asAdaptyError + let error = error ?? StoreKitManagerError.productPurchaseFailed(sk1Transaction.error).asAdaptyError + log.verbose("Finish failed transaction \(sk1Transaction) error: \(error)") result = .failure(error) } callMakePurchasesCompletionHandlers(productId, result) @@ -276,11 +274,17 @@ extension SK1QueueManager { private static var observer: SK1PaymentTransactionObserver? @AdaptyActor - static func startObserving(purchaseValidator: PurchaseValidator, productsManager: StoreKitProductsManager, storage: VariationIdStorage) -> SK1QueueManager? { + static func startObserving( + transactionSynchronizer: StoreKitTransactionSynchronizer, + subscriptionOfferSigner: StoreKitSubscriptionOfferSigner, + productsManager: StoreKitProductsManager, + storage: PurchasePayloadStorage + ) -> SK1QueueManager? { guard observer == nil else { return nil } let manager = SK1QueueManager( - purchaseValidator: purchaseValidator, + transactionSynchronizer: transactionSynchronizer, + subscriptionOfferSigner: subscriptionOfferSigner, productsManager: productsManager, storage: storage ) diff --git a/Sources/StoreKit/SK1TransactionObserver.swift b/Sources/StoreKit/SK1TransactionObserver.swift index daca223b0..8171aab1d 100644 --- a/Sources/StoreKit/SK1TransactionObserver.swift +++ b/Sources/StoreKit/SK1TransactionObserver.swift @@ -10,12 +10,15 @@ import StoreKit private let log = Log.sk1QueueManager actor SK1TransactionObserver: Sendable { - private let purchaseValidator: PurchaseValidator - private let productsManager: StoreKitProductsManager - - fileprivate init(purchaseValidator: PurchaseValidator, productsManager: StoreKitProductsManager) { - self.purchaseValidator = purchaseValidator - self.productsManager = productsManager + private let transactionSynchronizer: StoreKitTransactionSynchronizer + private let sk1ProductsManager: SK1ProductsManager + + fileprivate init( + transactionSynchronizer: StoreKitTransactionSynchronizer, + sk1ProductsManager: SK1ProductsManager + ) { + self.transactionSynchronizer = transactionSynchronizer + self.sk1ProductsManager = sk1ProductsManager } fileprivate func updatedTransactions(_ transactions: [SKPaymentTransaction]) async { @@ -29,24 +32,23 @@ actor SK1TransactionObserver: Sendable { )) guard sk1Transaction.transactionState == .purchased else { continue } - guard let id = sk1Transaction.transactionIdentifier else { + guard let sk1Transaction = SK1TransactionWithIdentifier(sk1Transaction) else { log.error("received purchased transaction without identifier") continue } - let sk1Transaction = SK1TransactionWithIdentifier(sk1Transaction, id: id) - Task.detached { - let transaction = await self.productsManager.fillPurchasedTransaction( - paywallVariationId: nil, - persistentPaywallVariationId: nil, - persistentOnboardingVariationId: nil, - sk1Transaction: sk1Transaction + let productOrNil = try? await self.sk1ProductsManager.fetchProduct( + id: sk1Transaction.unfProductId, + fetchPolicy: .returnCacheDataElseLoad ) - _ = try await self.purchaseValidator.validatePurchase( - profileId: nil, - transaction: transaction, + try await self.transactionSynchronizer.report( + .init( + product: productOrNil, + transaction: sk1Transaction + ), + payload: .init(userId: ProfileStorage.userId), reason: .observing ) } @@ -59,12 +61,15 @@ extension SK1TransactionObserver { private static var observer: ObserverWrapper? @AdaptyActor - static func startObserving(purchaseValidator: PurchaseValidator, productsManager: StoreKitProductsManager) { + static func startObserving( + transactionSynchronizer: StoreKitTransactionSynchronizer, + sk1ProductsManager: SK1ProductsManager + ) { guard observer == nil else { return } let observer = ObserverWrapper(SK1TransactionObserver( - purchaseValidator: purchaseValidator, - productsManager: productsManager + transactionSynchronizer: transactionSynchronizer, + sk1ProductsManager: sk1ProductsManager )) self.observer = observer diff --git a/Sources/StoreKit/SK2PaywallProducts.swift b/Sources/StoreKit/SK2PaywallProducts.swift index 065ec0ae5..6cab6a2ca 100644 --- a/Sources/StoreKit/SK2PaywallProducts.swift +++ b/Sources/StoreKit/SK2PaywallProducts.swift @@ -21,13 +21,14 @@ extension Adapty { ) .compactMap { sk2Product in let vendorId = sk2Product.id - guard let reference = paywall.products.first(where: { $0.vendorId == vendorId }) else { + guard let reference = paywall.products.first(where: { $0.productInfo.vendorId == vendorId }) else { return nil } return AdaptySK2PaywallProductWithoutDeterminingOffer( skProduct: sk2Product, adaptyProductId: reference.adaptyProductId, + productInfo: reference.productInfo, paywallProductIndex: reference.paywallProductIndex, variationId: paywall.variationId, paywallABTestName: paywall.placement.abTestName, @@ -38,8 +39,8 @@ extension Adapty { } func getSK2PaywallProduct( - vendorProductId: String, adaptyProductId: String, + productInfo: BackendProductInfo, paywallProductIndex: Int, subscriptionOfferIdentifier: AdaptySubscriptionOffer.Identifier?, variationId: String, @@ -48,14 +49,14 @@ extension Adapty { webPaywallBaseUrl: URL?, productsManager: SK2ProductsManager ) async throws(AdaptyError) -> AdaptySK2PaywallProduct { - let sk2Product = try await productsManager.fetchSK2Product(id: vendorProductId, fetchPolicy: .returnCacheDataElseLoad) + let sk2Product = try await productsManager.fetchSK2Product(id: productInfo.vendorId, fetchPolicy: .returnCacheDataElseLoad) let subscriptionOffer: AdaptySubscriptionOffer? = if let subscriptionOfferIdentifier { if let offer = sk2Product.subscriptionOffer(by: subscriptionOfferIdentifier) { offer } else { - throw StoreKitManagerError.invalidOffer("StoreKit2 product don't have offer id: `\(subscriptionOfferIdentifier.identifier ?? "nil")` with type:\(subscriptionOfferIdentifier.asOfferType.rawValue) ").asAdaptyError + throw StoreKitManagerError.invalidOffer("StoreKit2 product don't have offer id: `\(subscriptionOfferIdentifier.offerId ?? "nil")` with type:\(subscriptionOfferIdentifier.offerType.rawValue) ").asAdaptyError } } else { nil @@ -64,6 +65,7 @@ extension Adapty { return AdaptySK2PaywallProduct( skProduct: sk2Product, adaptyProductId: adaptyProductId, + productInfo: productInfo, paywallProductIndex: paywallProductIndex, subscriptionOffer: subscriptionOffer, variationId: variationId, @@ -83,7 +85,7 @@ extension Adapty { ) .compactMap { sk2Product in let vendorId = sk2Product.id - guard let reference = paywall.products.first(where: { $0.vendorId == vendorId }) else { + guard let reference = paywall.products.first(where: { $0.productInfo.vendorId == vendorId }) else { return nil } @@ -109,6 +111,7 @@ extension Adapty { AdaptySK2PaywallProduct( skProduct: $0.product, adaptyProductId: $0.reference.adaptyProductId, + productInfo: $0.reference.productInfo, paywallProductIndex: $0.reference.paywallProductIndex, subscriptionOffer: $0.offer, variationId: paywall.variationId, @@ -147,9 +150,11 @@ extension Adapty { guard !tuple.determinedOffer else { return (tuple.product, tuple.reference, tuple.offer) } if let subscriptionGroupId = tuple.subscriptionGroupId, - let winBackOfferId = tuple.reference.winBackOfferId { + let winBackOfferId = tuple.reference.winBackOfferId + { if eligibleWinBackOfferIds[subscriptionGroupId]?.contains(winBackOfferId) ?? false, - let winBackOffer = winBackOffer(with: winBackOfferId, from: tuple.product) { + let winBackOffer = winBackOffer(with: winBackOfferId, from: tuple.product) + { return (tuple.product, tuple.reference, winBackOffer) } @@ -199,7 +204,7 @@ extension Adapty { private func winBackOfferExist(with offerId: String?, from sk2Product: SK2Product) -> Bool { guard let offerId else { return false } - guard sk2Product.unfWinBackOffer(byId: offerId) != nil else { + guard sk2Product.sk2ProductSubscriptionOffer(by: .winBack(offerId)) != nil else { log.warn("no win back offer found with id:\(offerId) in productId:\(sk2Product.id)") return false } diff --git a/Sources/StoreKit/SK2ProductsManager+PurchasedTransaction.swift b/Sources/StoreKit/SK2ProductsManager+PurchasedTransaction.swift deleted file mode 100644 index 6fd8b96df..000000000 --- a/Sources/StoreKit/SK2ProductsManager+PurchasedTransaction.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// SK2ProductsManager+PurchasedTransaction.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 06.10.2024 -// - -import Foundation - -private let log = Log.sk2ProductManager - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -extension SK2ProductsManager { - func fillPurchasedTransaction( - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk1Transaction: SK1TransactionWithIdentifier - ) async -> PurchasedTransaction { - await .init( - sk2Product: try? fetchSK2Product( - id: sk1Transaction.unfProductID, - fetchPolicy: .returnCacheDataElseLoad - ), - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk1Transaction: sk1Transaction - ) - } - - func fillPurchasedTransaction( - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk2Transaction: SK2Transaction - ) async -> PurchasedTransaction { - await .init( - sk2Product: try? fetchSK2Product( - id: sk2Transaction.unfProductID, - fetchPolicy: .returnCacheDataElseLoad - ), - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk2Transaction: sk2Transaction - ) - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -extension PurchasedTransaction { - fileprivate init( - sk2Product: SK2Product?, - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk1Transaction: SK1TransactionWithIdentifier - ) { - let offer = PurchasedTransaction.SubscriptionOffer( - sk1Transaction: sk1Transaction, - sk2Product: sk2Product - ) - - self.init( - transactionId: sk1Transaction.unfIdentifier, - originalTransactionId: sk1Transaction.unfOriginalIdentifier, - vendorProductId: sk1Transaction.unfProductID, - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - price: sk2Product?.price, - priceLocale: sk2Product?.priceFormatStyle.locale.unfCurrencyCode, - storeCountry: sk2Product?.priceFormatStyle.locale.unfRegionCode, - subscriptionOffer: offer, - environment: nil - ) - } - - init( - sk2Product: SK2Product?, - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk2Transaction: SK2Transaction - ) { - let offer: PurchasedTransaction.SubscriptionOffer? = { - if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { - return .init( - sk2TransactionOffer: sk2Transaction.offer, - sk2Product: sk2Product - ) - } - return .init( - sk2Transaction: sk2Transaction, - sk2Product: sk2Product - ) - }() - - self.init( - transactionId: sk2Transaction.unfIdentifier, - originalTransactionId: sk2Transaction.unfOriginalIdentifier, - vendorProductId: sk2Transaction.unfProductID, - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - price: sk2Product?.price, - priceLocale: sk2Product?.priceFormatStyle.locale.unfCurrencyCode, - storeCountry: sk2Product?.priceFormatStyle.locale.unfRegionCode, - subscriptionOffer: offer, - environment: sk2Transaction.unfEnvironment - ) - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -private extension PurchasedTransaction.SubscriptionOffer { - init?( - sk1Transaction: SK1TransactionWithIdentifier, - sk2Product: SK2Product? - ) { - if let discountIdentifier = sk1Transaction.unfOfferId { - if let sk2ProductOffer = sk2Product?.subscriptionOffer(byType: .promotional, withId: discountIdentifier) { - self.init( - id: discountIdentifier, - period: sk2ProductOffer.period.asAdaptySubscriptionPeriod, - paymentMode: sk2ProductOffer.paymentMode.asPaymentMode, - offerType: .promotional, - price: sk2ProductOffer.price - ) - } else { - self.init(id: discountIdentifier, offerType: .promotional) - } - } else if let offer = sk2Product?.subscription?.introductoryOffer { - self.init( - id: nil, - period: offer.period.asAdaptySubscriptionPeriod, - paymentMode: offer.paymentMode.asPaymentMode, - offerType: .introductory, - price: offer.price - ) - } else { - return nil - } - } - - init?( - sk2Transaction: SK2Transaction, - sk2Product: SK2Product? - ) { - guard let offerType = sk2Transaction.unfOfferType?.asPurchasedTransactionOfferType else { return nil } - let sk2ProductOffer = sk2Product?.subscriptionOffer( - byType: offerType, - withId: sk2Transaction.unfOfferId - ) - self = .init( - id: sk2Transaction.unfOfferId, - period: (sk2ProductOffer?.period)?.asAdaptySubscriptionPeriod, - paymentMode: (sk2ProductOffer?.paymentMode)?.asPaymentMode ?? .unknown, - offerType: offerType, - price: sk2ProductOffer?.price - ) - } - - @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) - init?( - sk2TransactionOffer: SK2Transaction.Offer?, - sk2Product: SK2Product? - ) { - guard let sk2TransactionOffer else { return nil } - let sk2ProductOffer = sk2Product?.subscriptionOffer( - byType: sk2TransactionOffer.type.asPurchasedTransactionOfferType, - withId: sk2TransactionOffer.id - ) - self = .init( - id: sk2TransactionOffer.id, - period: (sk2ProductOffer?.period).map { $0.asAdaptySubscriptionPeriod }, - paymentMode: sk2TransactionOffer.paymentMode.map { $0.asPaymentMode } ?? .unknown, - offerType: sk2TransactionOffer.type.asPurchasedTransactionOfferType, - price: sk2ProductOffer?.price - ) - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -private extension SK2Product { - func subscriptionOffer( - byType offerType: PurchasedTransaction.OfferType, - withId offerId: String? - ) -> SK2Product.SubscriptionOffer? { - guard let subscription else { return nil } - - switch offerType { - case .introductory: - return subscription.introductoryOffer - case .promotional: - if let offerId { - return subscription.promotionalOffers.first { $0.id == offerId } - } - case .code: - return nil - case .winBack: - if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *), let offerId { - return subscription.winBackOffers.first { $0.id == offerId } - } - default: - return nil - } - - return nil - } -} diff --git a/Sources/StoreKit/SK2ProductsManager.swift b/Sources/StoreKit/SK2ProductsManager.swift index 65e99c853..d676373e2 100644 --- a/Sources/StoreKit/SK2ProductsManager.swift +++ b/Sources/StoreKit/SK2ProductsManager.swift @@ -10,20 +10,20 @@ import Foundation private let log = Log.sk2ProductManager @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -actor SK2ProductsManager: StoreKitProductsManager { +actor SK2ProductsManager { private let apiKeyPrefix: String - private let storage: ProductVendorIdsStorage + private let storage: BackendProductInfoStorage private let session: Backend.MainExecutor private var products = [String: SK2Product]() private let sk2ProductsFetcher = SK2ProductFetcher() - init(apiKeyPrefix: String, session: Backend.MainExecutor, storage: ProductVendorIdsStorage) { + init(apiKeyPrefix: String, session: Backend.MainExecutor, storage: BackendProductInfoStorage) { self.apiKeyPrefix = apiKeyPrefix self.session = session self.storage = storage Task { - await fetchAllProducts() + await prefetchAllProducts() } } @@ -33,19 +33,32 @@ actor SK2ProductsManager: StoreKitProductsManager { fetchingAllProducts = false } - private func fetchAllProducts() async { + func storeProductInfo(productInfo: [BackendProductInfo]) async { + await storage.set(productInfo: productInfo) + } + + func getProductInfo(vendorId: String) async -> BackendProductInfo? { + await storage.productInfo(by: vendorId) + } + + private func fetchProductsInfo() async throws(HTTPError) -> [BackendProductInfo] { + let response = try await session.fetchProductInfo(apiKeyPrefix: apiKeyPrefix) + await storage.set(allProductInfo: response) + return response + } + + private func prefetchAllProducts() async { guard !fetchingAllProducts else { return } fetchingAllProducts = true do { - let response = try await session.fetchAllProductVendorIds(apiKeyPrefix: apiKeyPrefix) - await storage.set(productVendorIds: response) + _ = try await fetchProductsInfo() } catch { guard !error.isCancelled else { return } Task.detached(priority: .utility) { [weak self] in try? await Task.sleep(duration: .seconds(2)) await self?.finishFetchingAllProducts() - await self?.fetchAllProducts() // TODO: recursion ??? + await self?.prefetchAllProducts() // TODO: recursion ??? } return } @@ -87,7 +100,7 @@ extension SK2ProductsManager { func fetchSK2Products(ids productIds: Set, fetchPolicy: ProductsFetchPolicy = .default, retryCount: Int = 3) async throws(AdaptyError) -> [SK2Product] { - guard !productIds.isEmpty else { + guard productIds.isNotEmpty else { throw StoreKitManagerError.noProductIDsFound().asAdaptyError } @@ -100,7 +113,7 @@ extension SK2ProductsManager { let products = try await sk2ProductsFetcher.fetchProducts(ids: productIds, retryCount: retryCount) - guard !products.isEmpty else { + guard products.isNotEmpty else { throw StoreKitManagerError.noProductIDsFound().asAdaptyError } diff --git a/Sources/StoreKit/SK2Purchaser.swift b/Sources/StoreKit/SK2Purchaser.swift index 42f5d7599..e1daace3d 100644 --- a/Sources/StoreKit/SK2Purchaser.swift +++ b/Sources/StoreKit/SK2Purchaser.swift @@ -9,13 +9,23 @@ import StoreKit private let log = Log.sk2TransactionManager +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) actor SK2Purchaser { - private let purchaseValidator: PurchaseValidator - private let storage: VariationIdStorage - - private init(purchaseValidator: PurchaseValidator, storage: VariationIdStorage) { - self.purchaseValidator = purchaseValidator + private let transactionSynchronizer: StoreKitTransactionSynchronizer + private let subscriptionOfferSigner: StoreKitSubscriptionOfferSigner + private let storage: PurchasePayloadStorage + private let sk2ProductManager: SK2ProductsManager + + private init( + transactionSynchronizer: StoreKitTransactionSynchronizer, + subscriptionOfferSigner: StoreKitSubscriptionOfferSigner, + storage: PurchasePayloadStorage, + sk2ProductManager: SK2ProductsManager + ) { + self.transactionSynchronizer = transactionSynchronizer + self.subscriptionOfferSigner = subscriptionOfferSigner self.storage = storage + self.sk2ProductManager = sk2ProductManager } @AdaptyActor @@ -23,104 +33,106 @@ actor SK2Purchaser { @AdaptyActor static func startObserving( - purchaseValidator: PurchaseValidator, - productsManager: StoreKitProductsManager, - storage: VariationIdStorage + transactionSynchronizer: StoreKitTransactionSynchronizer, + subscriptionOfferSigner: StoreKitSubscriptionOfferSigner, + sk2ProductsManager: SK2ProductsManager, + storage: PurchasePayloadStorage ) -> SK2Purchaser? { - guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), !isObservingStarted else { - return nil - } - Task { - for await verificationResult in SK2Transaction.updates { - switch verificationResult { + for await sk2SignedTransaction in SK2Transaction.updates { + switch sk2SignedTransaction { case let .unverified(sk2Transaction, error): - log.error("Transaction \(sk2Transaction.unfIdentifier) (originalID: \(sk2Transaction.unfOriginalIdentifier), productID: \(sk2Transaction.unfProductID)) is unverified. Error: \(error.localizedDescription)") + log.error("Transaction \(sk2Transaction.unfIdentifier) (originalId: \(sk2Transaction.unfOriginalIdentifier), productId: \(sk2Transaction.unfProductId)) is unverified. Error: \(error.localizedDescription)") await sk2Transaction.finish() - continue + log.warn("Finish unverified updated transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId) error: \(error.localizedDescription)") + + await storage.removePurchasePayload(forTransaction: sk2Transaction) + await storage.removeUnfinishedTransaction(sk2Transaction.unfIdentifier) + Adapty.trackSystemEvent(AdaptyAppleRequestParameters( + methodName: .finishTransaction, + params: sk2Transaction.logParams(other: ["unverified": error.localizedDescription]) + )) case let .verified(sk2Transaction): - log.debug("Transaction \(sk2Transaction.unfIdentifier) (originalID: \(sk2Transaction.unfOriginalIdentifier), productID: \(sk2Transaction.unfProductID), revocationDate:\(sk2Transaction.revocationDate?.description ?? "nil"), expirationDate:\(sk2Transaction.expirationDate?.description ?? "nil") \((sk2Transaction.expirationDate.map { $0 < Date() } ?? false) ? "[expired]" : "") , isUpgraded:\(sk2Transaction.isUpgraded) ) ") + log.debug("Transaction \(sk2Transaction.unfIdentifier) (originalId: \(sk2Transaction.unfOriginalIdentifier), productId: \(sk2Transaction.unfProductId), revocationDate:\(sk2Transaction.revocationDate?.description ?? "nil"), expirationDate:\(sk2Transaction.expirationDate?.description ?? "nil") \((sk2Transaction.expirationDate.map { $0 < Date() } ?? false) ? "[expired]" : "") , isUpgraded:\(sk2Transaction.isUpgraded) ) ") Task.detached { - let (paywallVariationId, persistentPaywallVariationId) = await storage.getPaywallVariationIds(for: sk2Transaction.productID) - - let purchasedTransaction = await productsManager.fillPurchasedTransaction( - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: persistentPaywallVariationId, - persistentOnboardingVariationId: nil, - sk2Transaction: sk2Transaction + let productOrNil = try? await sk2ProductsManager.fetchProduct( + id: sk2Transaction.unfProductId, + fetchPolicy: .returnCacheDataElseLoad ) do { - _ = try await purchaseValidator.validatePurchase( - profileId: nil, - transaction: purchasedTransaction, - reason: .sk2Updates + var xxx = true + if await AdaptyConfiguration.transactionFinishBehavior == .manual { + xxx = await storage.addUnfinishedTransaction(sk2Transaction.unfIdentifier) + } + + log.debug("###UDSTED### \(xxx)") + await Adapty.callDelegate { $0.onUnfinishedTransaction(AdaptyUnfinishedTransaction(sk2SignedTransaction: sk2SignedTransaction)) } + + try await transactionSynchronizer.report( + .init( + product: productOrNil, + transaction: sk2Transaction + ), + payload: storage.purchasePayload( + byTransaction: sk2Transaction, + orCreateFor: ProfileStorage.userId + ), + reason: .observing ) - await sk2Transaction.finish() + guard await storage.canFinishSyncedTransaction(sk2Transaction.unfIdentifier) else { + log.info("Updated transaction synced: \(sk2Transaction), manual finish required for product: \(sk2Transaction.unfProductId)") + return + } + + await transactionSynchronizer.finish(transaction: sk2Transaction, recived: .updates) - await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( - methodName: .finishTransaction, - params: sk2Transaction.logParams - )) + log.info("Finish updated transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId)") - log.info("Updated transaction: \(sk2Transaction) for product: \(sk2Transaction.productID)") } catch { - log.error("Failed to validate transaction: \(sk2Transaction) for product: \(sk2Transaction.productID)") + _ = await transactionSynchronizer.recalculateOfflineAccessLevels(with: sk2Transaction) + log.error("Failed to validate transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId)") } } } } } - isObservingStarted = true return SK2Purchaser( - purchaseValidator: purchaseValidator, - storage: storage + transactionSynchronizer: transactionSynchronizer, + subscriptionOfferSigner: subscriptionOfferSigner, + storage: storage, + sk2ProductManager: sk2ProductsManager ) } func makePurchase( - profileId: String, + userId: AdaptyUserId, appAccountToken: UUID?, product: AdaptyPaywallProduct ) async throws(AdaptyError) -> AdaptyPurchaseResult { - guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *), - let sk2Product = product.sk2Product - else { - throw AdaptyError.cantMakePayments() + guard let product = product as? AdaptySK2PaywallProduct else { + throw .cantMakePayments() } var options = Set() + // options.insert(.simulatesAskToBuyInSandbox(true)) + if let uuid = appAccountToken { options.insert(.appAccountToken(uuid)) } - - switch product.subscriptionOffer { - case .none: - break - case let .some(offer): - switch offer.offerIdentifier { - case .introductory: - break - - case let .winBack(offerId): - if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *), - let winBackOffer = sk2Product.unfWinBackOffer(byId: offerId) - { - options.insert(.winBackOffer(winBackOffer)) - } else { - throw StoreKitManagerError.invalidOffer("StoreKit2 Not found winBackOfferId:\(offerId) for productId: \(product.vendorProductId)").asAdaptyError - } + if let offer = product.subscriptionOffer { + switch offer.offerIdentifier { case let .promotional(offerId): - let response = try await purchaseValidator.signSubscriptionOffer( - profileId: profileId, - vendorProductId: product.vendorProductId, - offerId: offerId + let response = try await subscriptionOfferSigner.sign( + offerId: offerId, + subscriptionVendorId: product.vendorProductId, + for: userId ) options.insert( @@ -132,30 +144,38 @@ actor SK2Purchaser { timestamp: response.timestamp ) ) - } - } - - await storage.setPaywallVariationIds(product.variationId, for: sk2Product.id) - let persistentOnboardingVariationId = await storage.getOnboardingVariationId() - let result = try await makePurchase(sk2Product, options, product.variationId, persistentOnboardingVariationId) + case let .winBack(offerId): + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *), + let winBackOffer = product.skProduct.sk2ProductSubscriptionOffer(by: .winBack(offerId)) + { + options.insert(.winBackOffer(winBackOffer)) + } else { + throw StoreKitManagerError.invalidOffer("StoreKit2 Not found winBackOfferId:\(offerId) for productId: \(product.vendorProductId)").asAdaptyError + } - switch result { - case .pending: - break - default: - storage.removePaywallVariationIds(for: sk2Product.id) + default: + break + } } - return result + await sk2ProductManager.storeProductInfo(productInfo: [product.productInfo]) + await storage.setPaywallVariationId(product.variationId, productId: product.vendorProductId, userId: userId) + let payload = PurchasePayload( + userId: userId, + paywallVariationId: product.variationId, + persistentPaywallVariationId: product.variationId, + persistentOnboardingVariationId: await storage.onboardingVariationId() + ) + return try await makePurchase(product.skProduct, options, payload, for: userId) } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private func makePurchase( _ sk2Product: SK2Product, _ options: Set, - _ paywallVariationId: String?, - _ persistentOnboardingVariationId: String? + _ payload: PurchasePayload, + for userId: AdaptyUserId ) async throws(AdaptyError) -> AdaptyPurchaseResult { let stamp = Log.stamp @@ -184,7 +204,7 @@ actor SK2Purchaser { switch purchaseResult { case let .success(verificationResult): switch verificationResult { - case .verified: + case let .verified(sk2Transaction): await Adapty.trackSystemEvent(AdaptyAppleResponseParameters( methodName: .productPurchase, stamp: stamp, @@ -192,15 +212,24 @@ actor SK2Purchaser { "verified": true, ] )) + await storage.setPurchasePayload(payload, forTransaction: sk2Transaction) sk2SignedTransaction = verificationResult - case let .unverified(transaction, error): + case let .unverified(sk2Transaction, error): await Adapty.trackSystemEvent(AdaptyAppleResponseParameters( methodName: .productPurchase, stamp: stamp, error: error.localizedDescription )) - log.error("Unverified purchase transaction of product: \(sk2Product.id) \(error.localizedDescription)") - await transaction.finish() + + await sk2Transaction.finish() + + log.error("Finish unverified purchase transaction: \(sk2Transaction) of product: \(sk2Transaction.unfProductId) error: \(error.localizedDescription)") + await storage.removePurchasePayload(forTransaction: sk2Transaction) + await storage.removeUnfinishedTransaction(sk2Transaction.unfIdentifier) + await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( + methodName: .finishTransaction, + params: sk2Transaction.logParams(other: ["unverified": error.localizedDescription]) + )) throw StoreKitManagerError.transactionUnverified(error).asAdaptyError } case .pending: @@ -237,33 +266,42 @@ actor SK2Purchaser { let sk2Transaction = sk2SignedTransaction.unsafePayloadValue - let purchasedTransaction = PurchasedTransaction( - sk2Product: sk2Product, - paywallVariationId: paywallVariationId, - persistentPaywallVariationId: paywallVariationId, - persistentOnboardingVariationId: persistentOnboardingVariationId, - sk2Transaction: sk2Transaction - ) - do { - let response = try await purchaseValidator.validatePurchase( - profileId: nil, - transaction: purchasedTransaction, - reason: .purchasing + var xxx = true + + if await AdaptyConfiguration.transactionFinishBehavior == .manual { + xxx = await storage.addUnfinishedTransaction(sk2Transaction.unfIdentifier) + } + + log.debug("###PURCHASED### \(xxx)") + + await Adapty.callDelegate { $0.onUnfinishedTransaction(AdaptyUnfinishedTransaction(sk2SignedTransaction: sk2SignedTransaction)) } + + let profile = try await transactionSynchronizer.validate( + .init( + product: sk2Product.asAdaptyProduct, + transaction: sk2Transaction + ), + payload: payload ) - await sk2Transaction.finish() + guard await storage.canFinishSyncedTransaction(sk2Transaction.unfIdentifier) else { + log.info("Successfully purchased transaction synced: \(sk2Transaction), manual finish required for product: \(sk2Transaction.unfProductId)") + return .success(profile: profile, transaction: sk2SignedTransaction) + } - await Adapty.trackSystemEvent(AdaptyAppleRequestParameters( - methodName: .finishTransaction, - params: sk2Transaction.logParams - )) + await transactionSynchronizer.finish(transaction: sk2Transaction, recived: .purchased) + log.info("Finish purchased transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId) after synce") + + return .success(profile: profile, transaction: sk2SignedTransaction) - log.info("Successfully purchased product: \(sk2Product.id) with transaction: \(sk2Transaction)") - return .success(profile: response.value, transaction: sk2SignedTransaction) } catch { log.error("Failed to validate transaction: \(sk2Transaction) for product: \(sk2Product.id)") - throw StoreKitManagerError.transactionUnverified(error).asAdaptyError + if let profile = await transactionSynchronizer.recalculateOfflineAccessLevels(with: sk2Transaction) { + return .success(profile: profile, transaction: sk2SignedTransaction) + } else { + throw StoreKitManagerError.transactionUnverified(error).asAdaptyError + } } } } diff --git a/Sources/StoreKit/SK2TransactionManager.swift b/Sources/StoreKit/SK2TransactionManager.swift index 38e1eddf5..c62f0b50d 100644 --- a/Sources/StoreKit/SK2TransactionManager.swift +++ b/Sources/StoreKit/SK2TransactionManager.swift @@ -10,95 +10,259 @@ import StoreKit private let log = Log.sk2TransactionManager @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) -actor SK2TransactionManager: StoreKitTransactionManager { - private let session: Backend.MainExecutor +actor SK2TransactionManager { + private let httpSession: Backend.MainExecutor + private let storage: PurchasePayloadStorage - private var lastTransactionCached: SK2Transaction? - private var syncing: (task: AdaptyResultTask?>, profileId: String)? + private var lastTransactionOriginalIdentifier: String? + private var verifiedCurrentEntitlementsCached: (value: [SK2Transaction], at: Date)? - init(session: Backend.MainExecutor) { - self.session = session + private static let cacheDuration: TimeInterval = 60 * 5 + + private var syncingTransactionsHistory: (task: AdaptyResultTask, userId: AdaptyUserId)? + private var syncingUnfinishedTransactions: AdaptyResultTask? + + init( + httpSession: Backend.MainExecutor, + storage: PurchasePayloadStorage + ) { + self.httpSession = httpSession + self.storage = storage } - func syncTransactions(for profileId: String) async throws(AdaptyError) -> VH? { - let task: AdaptyResultTask?> - if let syncing, syncing.profileId == profileId { - task = syncing.task + func clearCache() { + verifiedCurrentEntitlementsCached = nil + } + + fileprivate func getLastTransactionOriginalIdentifier() async -> String? { + if let cached = lastTransactionOriginalIdentifier { + return cached + } else if let id = await SK2TransactionManager.fetchLastVerifiedTransaction()?.unfOriginalIdentifier { + lastTransactionOriginalIdentifier = id + return id } else { - task = Task { - do throws(AdaptyError) { - let value = try await syncLastTransaction(for: profileId) - return .success(value) - } catch { - return .failure(error) - } + return nil + } + } + + var hasUnfinishedTransactions: Bool { + get async { + await SK2Transaction.unfinished.contains { + if case .verified = $0 { true } else { false } } - syncing = (task, profileId) } - return try await task.value.get() } - private func syncLastTransaction(for profileId: String) async throws(AdaptyError) -> VH? { - defer { syncing = nil } + func getVerifiedCurrentEntitlements() async -> [SK2Transaction] { + if let cache = verifiedCurrentEntitlementsCached, + Date().timeIntervalSince(cache.at) < Self.cacheDuration + { + return cache.value + } + let transactions = await Self.fetchVerifiedCurrentEntitlements() - let lastTransaction: SK2Transaction + verifiedCurrentEntitlementsCached = (transactions, at: Date()) + return transactions + } - if let transaction = lastTransactionCached { - lastTransaction = transaction - } else if let transaction = await Self.lastTransaction { - lastTransactionCached = transaction - lastTransaction = transaction + func syncTransactionHistory(for userId: AdaptyUserId) async throws(AdaptyError) { + let task: AdaptyResultTask + if let syncing = syncingTransactionsHistory, userId.isEqualProfileId(syncing.userId) { + task = syncing.task } else { - return nil + task = Task { + defer { syncingTransactionsHistory = nil } + guard let sdk = await Adapty.optionalSDK else { return .failure(AdaptyError.notActivated()) } + return await sdk.sendLastTransactionOriginalId(manager: self, for: userId) + } + syncingTransactionsHistory = (task, userId) + } + try await task.value.get() + } + + func syncUnfinishedTransactions() async throws(AdaptyError) { + let task: AdaptyResultTask + if let syncing = syncingUnfinishedTransactions { + task = syncing + } else { + task = Task { + defer { syncingUnfinishedTransactions = nil } + guard let sdk = await Adapty.optionalSDK else { return .failure(AdaptyError.notActivated()) } + return await sdk.sendUnfinishedTransactions(manager: self) + } + syncingUnfinishedTransactions = task + } + try await task.value.get() + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +private extension Adapty { + func sendLastTransactionOriginalId(manager: SK2TransactionManager, for userId: AdaptyUserId) async -> AdaptyResult { + guard let originalTransactionId = await manager.getLastTransactionOriginalIdentifier() else { + return .success(()) } - do { - return try await session.syncTransaction( - profileId: profileId, - originalTransactionId: lastTransaction.unfOriginalIdentifier + do throws(HTTPError) { + let response = try await httpSession.syncTransactionsHistory( + originalTransactionId: originalTransactionId, + for: userId ) + handleTransactionResponse(response) + return .success(()) } catch { - throw error.asAdaptyError + return .failure(error.asAdaptyError) } } - private static var lastTransaction: SK2Transaction? { - get async { - var lastTransaction: SK2Transaction? - let stamp = Log.stamp + func sendUnfinishedTransactions(manager: SK2TransactionManager) async -> AdaptyResult { + guard !observerMode else { return .success(()) } + let unfinishedTranasactions = await SK2TransactionManager.fetchUnfinishedTrunsactions() + guard !unfinishedTranasactions.isEmpty else { return .success(()) } + for sk2SignedTransaction in unfinishedTranasactions { + switch sk2SignedTransaction { + case let .unverified(sk2Transaction, error): + log.error("Unfinished transaction \(sk2Transaction.unfIdentifier) (originalId: \(sk2Transaction.unfOriginalIdentifier), productId: \(sk2Transaction.unfProductId)) is unverified. Error: \(error.localizedDescription)") + await sk2Transaction.finish() + log.warn("Finish unverified unfinished transaction: \(sk2Transaction) of product: \(sk2Transaction.unfProductId) error: \(error.localizedDescription)") - await Adapty.trackSystemEvent(AdaptyAppleRequestParameters(methodName: .getAllSK2Transactions, stamp: stamp)) - log.verbose("call SK2Transaction.all") + await purchasePayloadStorage.removePurchasePayload(forTransaction: sk2Transaction) + await purchasePayloadStorage.removeUnfinishedTransaction(sk2Transaction.unfIdentifier) + Adapty.trackSystemEvent(AdaptyAppleRequestParameters( + methodName: .finishTransaction, + params: sk2Transaction.logParams(other: ["unverified": error.localizedDescription]) + )) + case let .verified(sk2Transaction): + if await purchasePayloadStorage.isSyncedTransaction(sk2Transaction.unfIdentifier) { continue } - for await verificationResult in SK2Transaction.all { - guard case let .verified(transaction) = verificationResult else { - continue - } + let productOrNil = try? await productsManager.fetchProduct( + id: sk2Transaction.unfProductId, + fetchPolicy: .returnCacheDataElseLoad + ) - log.verbose("found transaction original-id: \(transaction.originalID), purchase date:\(transaction.purchaseDate)") + do { + try await report( + .init( + product: productOrNil, + transaction: sk2Transaction + ), + payload: purchasePayloadStorage.purchasePayload( + byTransaction: sk2Transaction, + orCreateFor: ProfileStorage.userId + ), + reason: .unfinished + ) - guard let lasted = lastTransaction, - transaction.purchaseDate < lasted.purchaseDate - else { - lastTransaction = transaction - continue + guard await purchasePayloadStorage.canFinishSyncedTransaction(sk2Transaction.unfIdentifier) else { + log.info("Unfinished transaction synced: \(sk2Transaction), manual finish required for product: \(sk2Transaction.unfProductId)") + continue + } + + await finish(transaction: sk2Transaction, recived: .unfinished) + + log.info("Finish unfinished transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId) after sync") + } catch { + log.error("Failed to validate unfinished transaction: \(sk2Transaction) for product: \(sk2Transaction.unfProductId)") + return .failure(error) } } + } + return .success(()) + } +} - let params: EventParameters? = - if let lastTransaction { - [ - "original_transaction_id": lastTransaction.unfOriginalIdentifier, - "transaction_id": lastTransaction.unfIdentifier, - "purchase_date": lastTransaction.purchaseDate, - ] - } else { - nil - } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension Adapty { + func getUnfinishedTransactions() async throws(AdaptyError) -> [AdaptyUnfinishedTransaction] { + let transactions = await SK2TransactionManager.fetchUnfinishedTrunsactions() + let ids = await purchasePayloadStorage.unfinishedTransactionIds() + guard !ids.isEmpty, !transactions.isEmpty else { return [] } + + return transactions.compactMap { + if ids.contains($0.unsafePayloadValue.unfIdentifier) { + AdaptyUnfinishedTransaction(sk2SignedTransaction: $0) + } else { + nil + } + } + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +private extension SK2TransactionManager { + static func fetchUnfinishedTrunsactions() async -> [SK2SignedTransaction] { + let stamp = Log.stamp + + await Adapty.trackSystemEvent(AdaptyAppleRequestParameters(methodName: .getUnfinishedSK2Transactions, stamp: stamp)) + log.verbose("call SK2Transaction.unfinished") + + let signedTransactions = await SK2Transaction.unfinished.reduce(into: []) { $0.append($1) } + + await Adapty.trackSystemEvent(AdaptyAppleResponseParameters(methodName: .getUnfinishedSK2Transactions, stamp: stamp, params: ["count": signedTransactions.count])) + + log.verbose("SK2Transaction.unfinished.count = \(signedTransactions.count)") + return signedTransactions + } + + private static func fetchVerifiedCurrentEntitlements() async -> [SK2Transaction] { + let stamp = Log.stamp - await Adapty.trackSystemEvent(AdaptyAppleResponseParameters(methodName: .getAllSK2Transactions, stamp: stamp, params: params)) + await Adapty.trackSystemEvent(AdaptyAppleRequestParameters(methodName: .getSK2CurrentEntitlements, stamp: stamp)) + log.verbose("call SK2Transaction.currentEntitlements") - return lastTransaction + let signedTransactions = await SK2Transaction.currentEntitlements.reduce(into: []) { $0.append($1) } + let transaction: [SK2Transaction] = signedTransactions.compactMap(\.verifiedTransaction) + + await Adapty.trackSystemEvent(AdaptyAppleResponseParameters(methodName: .getSK2CurrentEntitlements, stamp: stamp, params: ["total_count": signedTransactions.count, "verified_count": transaction.count])) + + log.verbose("SK2Transaction.currentEntitlements total count: \(signedTransactions.count), verified count: \(transaction.count)") + return transaction + } + + static func fetchLastVerifiedTransaction() async -> SK2Transaction? { + var lastTransaction: SK2Transaction? + let stamp = Log.stamp + + await Adapty.trackSystemEvent(AdaptyAppleRequestParameters(methodName: .getAllSK2Transactions, stamp: stamp)) + log.verbose("call SK2Transaction.all") + + for await transaction in SK2Transaction.all.compactMap(\.verifiedTransaction) { + log.verbose("found transaction originalId: \(transaction.unfOriginalIdentifier), purchase date:\(transaction.purchaseDate)") + + guard let lasted = lastTransaction, + transaction.purchaseDate < lasted.purchaseDate + else { + lastTransaction = transaction + continue + } + } + + let params: EventParameters? = + if let lastTransaction { + [ + "original_transaction_id": lastTransaction.unfOriginalIdentifier, + "transaction_id": lastTransaction.unfIdentifier, + "purchase_date": lastTransaction.purchaseDate, + ] + } else { + nil + } + + await Adapty.trackSystemEvent(AdaptyAppleResponseParameters(methodName: .getAllSK2Transactions, stamp: stamp, params: params)) + + return lastTransaction + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2SignedTransaction { + var verifiedTransaction: SK2Transaction? { + switch self { + case let .unverified(transaction, _): + log.warn("found unverified transaction originalId: \(transaction.unfOriginalIdentifier), purchase date:\(transaction.purchaseDate), environment: \(transaction.unfEnvironment)") + return nil + case let .verified(transaction): + return transaction } } } diff --git a/Sources/StoreKit/Storage/BackendIntroductoryOfferEligibilityStorage.swift b/Sources/StoreKit/Storage/BackendIntroductoryOfferEligibilityStorage.swift index 43397bf7f..bad8d1820 100644 --- a/Sources/StoreKit/Storage/BackendIntroductoryOfferEligibilityStorage.swift +++ b/Sources/StoreKit/Storage/BackendIntroductoryOfferEligibilityStorage.swift @@ -10,7 +10,7 @@ import Foundation private let log = Log.storage @AdaptyActor -final class BackendIntroductoryOfferEligibilityStorage: Sendable { +final class BackendIntroductoryOfferEligibilityStorage { private enum Constants { static let ineligibleProductIds = "AdaptySDK_Cached_Backend_Ineligible_Products" } diff --git a/Sources/StoreKit/Storage/BackendProductInfoStorage.swift b/Sources/StoreKit/Storage/BackendProductInfoStorage.swift new file mode 100644 index 000000000..eea8dfa86 --- /dev/null +++ b/Sources/StoreKit/Storage/BackendProductInfoStorage.swift @@ -0,0 +1,84 @@ +// +// BackendProductInfoStorage.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 30.09.2022. +// + +import Foundation + +private let log = Log.storage + +@BackendProductInfoStorage.InternalActor +final class BackendProductInfoStorage { + @globalActor + actor InternalActor { + package static let shared = InternalActor() + } + + private enum Constants { + static let productInfoStorageKey = "AdaptySDK_Cached_ProductVendorIds" + } + + private static let userDefaults = Storage.userDefaults + + private static var allProductInfo: [String: BackendProductInfo]? = { + do { + return try userDefaults.getJSON([BackendProductInfo].self, forKey: Constants.productInfoStorageKey)?.asProductInfoByVendorId + } catch { + log.warn(error.localizedDescription) + return nil + } + }() + + private static var allProductVendorIds: [String]? { + guard let vendorIds = allProductInfo?.keys else { return nil } + return Array(vendorIds) + } + + var allProductVendorIds: [String]? { Self.allProductVendorIds } + + func productInfo(by id: String) -> BackendProductInfo? { + Self.allProductInfo?[id] + } + + func set(allProductInfo: [BackendProductInfo]) { + do { + try Self.userDefaults.setJSON(allProductInfo, forKey: Constants.productInfoStorageKey) + Self.allProductInfo = allProductInfo.asProductInfoByVendorId + log.debug("Saving products info success.") + } catch { + log.error("Saving products info fail. \(error.localizedDescription)") + } + } + + func set(productInfo: [BackendProductInfo]) { + let vendorIds = productInfo.map(\.self).map(\.vendorId) + + do { + var allProductInfo = Self.allProductInfo ?? [:] + for productInfo in productInfo { + allProductInfo[productInfo.vendorId] = productInfo + } + try Self.userDefaults.setJSON(Array(allProductInfo.values), forKey: Constants.productInfoStorageKey) + Self.allProductInfo = allProductInfo + log.debug("Saving product info (vendorIds: \(vendorIds)) success.") + } catch { + log.error("Saving product info (vendorIds: \(vendorIds)) fail. \(error.localizedDescription)") + } + } + + static func clear() { + allProductInfo = nil + userDefaults.removeObject(forKey: Constants.productInfoStorageKey) + log.debug("Clear products info.") + } +} + +extension Sequence { + var asProductInfoByVendorId: [String: BackendProductInfo] { + Dictionary(map { ($0.vendorId, $0) }, uniquingKeysWith: { _, second in + second + }) + } +} diff --git a/Sources/StoreKit/Storage/ProductVendorIdsStorage.swift b/Sources/StoreKit/Storage/ProductVendorIdsStorage.swift deleted file mode 100644 index 5ae35bf9c..000000000 --- a/Sources/StoreKit/Storage/ProductVendorIdsStorage.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// ProductVendorIdsStorage.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 30.09.2022. -// - -import Foundation - -private let log = Log.storage - -@ProductVendorIdsStorage.InternalActor -final class ProductVendorIdsStorage: Sendable { - @globalActor - actor InternalActor { - package static let shared = InternalActor() - } - - private enum Constants { - static let productVendorIdsStorageKey = "AdaptySDK_Cached_ProductVendorIds" - } - - private static let userDefaults = Storage.userDefaults - - private static var allProductVendorIds: [String]? = { - do { - return try userDefaults.getJSON([String].self, forKey: Constants.productVendorIdsStorageKey) - } catch { - log.warn(error.localizedDescription) - return nil - } - }() - - var allProductVendorIds: [String]? { Self.allProductVendorIds } - - func set(productVendorIds vendorIds: [String]) { - do { - try Self.userDefaults.setJSON(vendorIds, forKey: Constants.productVendorIdsStorageKey) - Self.allProductVendorIds = vendorIds - log.debug("Saving vendor product ids success.") - } catch { - log.error("Saving vendor product ids fail. \(error.localizedDescription)") - } - } - - static func clear() { - allProductVendorIds = nil - userDefaults.removeObject(forKey: Constants.productVendorIdsStorageKey) - log.debug("Clear vendor product ids.") - } -} diff --git a/Sources/StoreKit/Storage/PurchasePayloadStorage.swift b/Sources/StoreKit/Storage/PurchasePayloadStorage.swift new file mode 100644 index 000000000..2493efc74 --- /dev/null +++ b/Sources/StoreKit/Storage/PurchasePayloadStorage.swift @@ -0,0 +1,315 @@ +// +// PurchasePayloadStorage.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 25.10.2022 +// + +import Foundation + +private let log = Log.storage + +@PurchasePayloadStorage.InternalActor +final class PurchasePayloadStorage { + @globalActor + actor InternalActor { + package static let shared = InternalActor() + } + + private enum Constants { + static let deprecatedPaywallVariationsIds = "AdaptySDK_Cached_Variations_Ids" + static let purchasePayloadByProductId = "AdaptySDK_Purchase_Payload_By_Product" + static let purchasePayloadByTransactionId = "AdaptySDK_Purchase_Payload_By_Transaction" + static let persistentPaywallVariationsIds = "AdaptySDK_Variations_Ids" + static let persistentOnboardingVariationsId = "AdaptySDK_Onboarding_Variation_Id" + static let unfinishedTransactionState = "AdaptySDK_Unfinished_Transaction_State" + } + + private static let userDefaults = Storage.userDefaults + + private static var purchasePayloadByProductId: [String: PurchasePayload] = (try? userDefaults.getJSON([String: PurchasePayload].self, forKey: Constants.purchasePayloadByProductId)) ?? [:] + + private static func setPurchasePayload(_ payload: PurchasePayload, forProductId productId: String) -> Bool { + guard payload != purchasePayloadByProductId.updateValue(payload, forKey: productId) else { return false } + try? userDefaults.setJSON(purchasePayloadByProductId, forKey: Constants.purchasePayloadByProductId) + log.debug("Saving variationsIds for paywall") + return true + } + + private static func removePurchasePayload(forProductId productId: String) -> Bool { + guard purchasePayloadByProductId.removeValue(forKey: productId) != nil else { return false } + try? userDefaults.setJSON(purchasePayloadByProductId, forKey: Constants.purchasePayloadByProductId) + log.debug("Remove purchase payload for productId = \(productId)") + return true + } + + private static var purchasePayloadByTransactionId: [String: PurchasePayload] = (try? userDefaults.getJSON([String: PurchasePayload].self, forKey: Constants.purchasePayloadByTransactionId)) ?? [:] + + private static func setPurchasePayload(_ payload: PurchasePayload, forTransactionId transactionId: String) -> Bool { + guard payload != purchasePayloadByTransactionId.updateValue(payload, forKey: transactionId) else { return false } + try? userDefaults.setJSON(purchasePayloadByTransactionId, forKey: Constants.purchasePayloadByTransactionId) + log.debug("Saving purchase payload for transactionId: \(transactionId)") + return true + } + + private static func removePurchasePayload(forTransactionId transactionId: String) -> Bool { + guard purchasePayloadByTransactionId.removeValue(forKey: transactionId) != nil else { return false } + try? userDefaults.setJSON(purchasePayloadByTransactionId, forKey: Constants.purchasePayloadByTransactionId) + log.debug("Remove purchase payload for transactionId = \(transactionId)") + return true + } + + private static var persistentPaywallVariationsIds: [String: String] = userDefaults + .dictionary(forKey: Constants.persistentPaywallVariationsIds) as? [String: String] ?? [:] + + private static func setPersistentPaywallVariationId(_ variationId: String, forProductId productId: String) -> Bool { + guard variationId != persistentPaywallVariationsIds.updateValue(variationId, forKey: productId) else { return false } + userDefaults.set(persistentPaywallVariationsIds, forKey: Constants.persistentPaywallVariationsIds) + return true + } + + private static var persistentOnboardingVariationsId: String? = userDefaults.string(forKey: Constants.persistentOnboardingVariationsId) + + private static func setPersistentOnboardingVariationId(_ variationId: String) -> Bool { + guard variationId != persistentOnboardingVariationsId else { return false } + persistentOnboardingVariationsId = variationId + userDefaults.set(persistentOnboardingVariationsId, forKey: Constants.persistentOnboardingVariationsId) + return true + } + + private static var unfinishedTransactionState: [String: Bool] = userDefaults + .dictionary(forKey: Constants.unfinishedTransactionState) as? [String: Bool] ?? [:] + + private static func setUnfinishedTransactionState(synced: Bool, forTransactionId transactionId: String) -> Bool { + let changing = unfinishedTransactionState[transactionId].map { !$0 && synced } ?? true + if changing { + unfinishedTransactionState[transactionId] = synced + userDefaults.set(unfinishedTransactionState, forKey: Constants.unfinishedTransactionState) + } + log.debug("Saving state (\(synced ? "unsynced" : "synced")) for transactionId: \(transactionId)") + return changing + } + + private static func removeUnfinishedTransactionState(forTransactionId transactionId: String) -> Bool { + guard unfinishedTransactionState.removeValue(forKey: transactionId) != nil else { return false } + userDefaults.set(unfinishedTransactionState, forKey: Constants.unfinishedTransactionState) + log.debug("Remove state for transactionId: \(transactionId)") + return true + } + + static func removeAllUnfinishedTransactionState() -> Bool { + guard !unfinishedTransactionState.isEmpty else { return false } + unfinishedTransactionState = [:] + userDefaults.removeObject(forKey: Constants.unfinishedTransactionState) + log.debug("Remove all states for transaction") + return true + } + + static func migration(for userId: AdaptyUserId) { + guard userDefaults.object(forKey: Constants.purchasePayloadByProductId) == nil else { return } + + let paywallVariationsIds = userDefaults + .dictionary(forKey: Constants.deprecatedPaywallVariationsIds) as? [String: String] + + guard let paywallVariationsIds, !paywallVariationsIds.isEmpty else { return } + + let onboardingId = userDefaults.string(forKey: Constants.persistentOnboardingVariationsId) + + let payloads = paywallVariationsIds.mapValues { variationId in + PurchasePayload( + userId: userId, + paywallVariationId: variationId, + persistentPaywallVariationId: variationId, + persistentOnboardingVariationId: onboardingId + ) + } + + do { + try userDefaults.setJSON(payloads, forKey: Constants.purchasePayloadByProductId) + userDefaults.removeObject(forKey: Constants.deprecatedPaywallVariationsIds) + log.info("PurchasePayloadStorage migration done") + } catch { + log.info("PurchasePayloadStorage migration failed with error: \(error)") + } + } + + static func clear() { + purchasePayloadByProductId = [:] + persistentOnboardingVariationsId = nil + + userDefaults.removeObject(forKey: Constants.deprecatedPaywallVariationsIds) + userDefaults.removeObject(forKey: Constants.persistentPaywallVariationsIds) + userDefaults.removeObject(forKey: Constants.purchasePayloadByProductId) + userDefaults.removeObject(forKey: Constants.purchasePayloadByTransactionId) + userDefaults.removeObject(forKey: Constants.persistentOnboardingVariationsId) + userDefaults.removeObject(forKey: Constants.unfinishedTransactionState) + + log.debug("Clear variationsIds for paywalls and onboarding.") + } +} + +extension PurchasePayloadStorage { + func purchasePayload(byProductId productId: String, orCreateFor userId: AdaptyUserId) -> PurchasePayload { + Self.purchasePayloadByProductId[productId] ?? .init( + userId: userId, + persistentPaywallVariationId: Self.persistentPaywallVariationsIds[productId], + persistentOnboardingVariationId: Self.persistentOnboardingVariationsId + ) + } + + func setPaywallVariationId(_ variationId: String, productId: String, userId: AdaptyUserId) { + if Self.setPurchasePayload( + .init( + userId: userId, + paywallVariationId: variationId, + persistentPaywallVariationId: variationId, + persistentOnboardingVariationId: Self.persistentOnboardingVariationsId + ), + forProductId: productId + ) { + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_set_variations_ids", + params: [ + "payload_by_product": Self.purchasePayloadByProductId, + ] + )) + } + } + + if Self.setPersistentPaywallVariationId(variationId, forProductId: productId) { + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_set_variations_ids_persistent", + params: [ + "variation_by_product": Self.persistentPaywallVariationsIds, + ] + )) + } + } + } + + func removePurchasePayload(forProductId productId: String) { + guard Self.removePurchasePayload(forProductId: productId) else { return } + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_set_variations_ids", + params: [ + "payload_by_product": Self.purchasePayloadByProductId, + ] + )) + } + } + + func purchasePayload(byTransaction transaction: SKTransaction, orCreateFor userId: AdaptyUserId) -> PurchasePayload { + if let payload = Self.purchasePayloadByTransactionId[transaction.unfIdentifier] { + return payload + } + + let payload = purchasePayload(byProductId: transaction.unfProductId, orCreateFor: userId) + setPurchasePayload(payload, forTransaction: transaction) + return payload + } + + func setPurchasePayload(_ payload: PurchasePayload, forTransaction transaction: SKTransaction) { + if Self.setPurchasePayload( + payload, + forTransactionId: transaction.unfIdentifier + ) { + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_set_variations_ids", + params: [ + "payload_by_transaction": Self.purchasePayloadByTransactionId, + ] + )) + } + } + removePurchasePayload(forProductId: transaction.unfProductId) + } + + func removePurchasePayload(forTransaction transaction: SKTransaction) { + if Self.removePurchasePayload(forTransactionId: transaction.unfIdentifier) { + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_set_variations_ids", + params: [ + "payload_by_transaction": Self.purchasePayloadByTransactionId, + ] + )) + } + } + removePurchasePayload(forProductId: transaction.unfProductId) + } + + func onboardingVariationId() -> String? { Self.persistentOnboardingVariationsId } + + func setOnboardingVariationId(_ variationId: String) { + if Self.setPersistentOnboardingVariationId(variationId) { + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_set_onboarding_variations_id", + params: [ + "onboarding_variation_id": variationId, + ] + )) + } + } + } + + func unfinishedTransactionIds() -> Set { + Set(Self.unfinishedTransactionState.keys) + } + + func isSyncedTransaction(_ transactionId: String) -> Bool { + Self.unfinishedTransactionState[transactionId] ?? false + } + + func addUnfinishedTransaction(_ transactionId: String) -> Bool { + let added = Self.setUnfinishedTransactionState(synced: false, forTransactionId: transactionId) + if added { + log.debug("Storage after add state of unfinishedTransaction:\(transactionId) all:\(Self.unfinishedTransactionState)") + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_change_unfinished_transaction", + params: [ + "state": Self.unfinishedTransactionState, + ] + )) + } + } + return added + } + + func canFinishSyncedTransaction(_ transactionId: String) -> Bool { + guard Self.unfinishedTransactionState[transactionId] != nil else { return true } + if Self.setUnfinishedTransactionState(synced: true, forTransactionId: transactionId) { + log.debug("Storage after change state of unfinishedTransaction:\(transactionId) all: \(Self.unfinishedTransactionState)") + + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_change_unfinished_transaction", + params: [ + "state": Self.unfinishedTransactionState, + ] + )) + } + } + return false + } + + func removeUnfinishedTransaction(_ transactionId: String) { + guard Self.unfinishedTransactionState[transactionId] != nil else { return } + if Self.removeUnfinishedTransactionState(forTransactionId: transactionId) { + log.debug("Storage after remove state of unfinishedTransaction:\(transactionId) all: \(Self.unfinishedTransactionState)") + Task { + await Adapty.trackSystemEvent(AdaptyInternalEventParameters( + eventName: "did_change_unfinished_transaction", + params: [ + "state": Self.unfinishedTransactionState, + ] + )) + } + } + } +} diff --git a/Sources/StoreKit/Storage/PurchasedTransactionStorage.swift b/Sources/StoreKit/Storage/PurchasedTransactionStorage.swift new file mode 100644 index 000000000..dd4bacd23 --- /dev/null +++ b/Sources/StoreKit/Storage/PurchasedTransactionStorage.swift @@ -0,0 +1,37 @@ +// +// PurchasedTransactionStorage.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 25.07.2025. +// + +import Foundation + +private let log = Log.storage + +@PurchasedTransactionStorage.InternalActor +final class PurchasedTransactionStorage { + @globalActor + actor InternalActor { + package static let shared = InternalActor() + } + + private enum Constants { + static let transactions = "AdaptySDK_Transactions" + } + + private static let userDefaults = Storage.userDefaults + + static var transactions: [Data]? = userDefaults.object(forKey: Constants.transactions) as? [Data] + + static func setTransactions(_ value: [Data]) { + userDefaults.set(value, forKey: Constants.transactions) + transactions = value + log.debug("Save Transactions success (count:\(value.count))") + } + + static func clear() { + userDefaults.removeObject(forKey: Constants.transactions) + transactions = nil + } +} diff --git a/Sources/StoreKit/Storage/VariationIdStorage.swift b/Sources/StoreKit/Storage/VariationIdStorage.swift deleted file mode 100644 index 8f5368ef3..000000000 --- a/Sources/StoreKit/Storage/VariationIdStorage.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// VariationIdStorage.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 25.10.2022 -// - -import Foundation - -private let log = Log.storage - -@VariationIdStorage.InternalActor -final class VariationIdStorage: Sendable { - @globalActor - actor InternalActor { - package static let shared = InternalActor() - } - - private enum Constants { - static let paywallVariationsIds = "AdaptySDK_Cached_Variations_Ids" - static let persistentPaywallVariationsIds = "AdaptySDK_Variations_Ids" - static let persistentOnboardingVariationsId = "AdaptySDK_Onboarding_Variation_Id" - } - - private static let userDefaults = Storage.userDefaults - - private static var paywallVariationsIds: [String: String] = userDefaults - .dictionary(forKey: Constants.paywallVariationsIds) as? [String: String] ?? [:] - private static var persistentPaywallVariationsIds: [String: String] = userDefaults - .dictionary(forKey: Constants.persistentPaywallVariationsIds) as? [String: String] ?? paywallVariationsIds - private static var persistentOnboardingVariationsId: String? = userDefaults.string(forKey: Constants.persistentOnboardingVariationsId) - - fileprivate var paywallVariationsIds: [String: String] { Self.paywallVariationsIds } - fileprivate var persistentPaywallVariationsIds: [String: String] { Self.persistentPaywallVariationsIds } - fileprivate var persistentOnboardingVariationsId: String? { Self.persistentOnboardingVariationsId } - - fileprivate func setPersistentOnboardingVariationId(_ variationId: String) -> Bool { - guard variationId != Self.persistentOnboardingVariationsId else { return false } - Self.persistentOnboardingVariationsId = variationId - Self.userDefaults.set(Self.persistentOnboardingVariationsId, forKey: Constants.persistentOnboardingVariationsId) - return true - } - - fileprivate func setPersistentPaywallVariationId(_ variationId: String, for productId: String) -> Bool { - guard variationId != Self.persistentPaywallVariationsIds.updateValue(variationId, forKey: productId) else { return false } - Self.userDefaults.set(Self.persistentPaywallVariationsIds, forKey: Constants.persistentPaywallVariationsIds) - return true - } - - fileprivate func setPaywallVariationId(_ variationId: String, for productId: String) -> Bool { - guard variationId != Self.paywallVariationsIds.updateValue(variationId, forKey: productId) else { return false } - Self.userDefaults.set(Self.paywallVariationsIds, forKey: Constants.paywallVariationsIds) - log.debug("Saving variationsIds for paywall") - return true - } - - fileprivate func removePaywallVariationId(for productId: String) -> Bool { - guard Self.paywallVariationsIds.removeValue(forKey: productId) != nil else { return false } - Self.userDefaults.set(Self.paywallVariationsIds, forKey: Constants.paywallVariationsIds) - log.debug("Saving variationsIds for paywall") - return true - } - - static func clear() { - paywallVariationsIds = [:] - persistentPaywallVariationsIds = [:] - persistentOnboardingVariationsId = nil - - userDefaults.removeObject(forKey: Constants.paywallVariationsIds) - userDefaults.removeObject(forKey: Constants.persistentPaywallVariationsIds) - userDefaults.removeObject(forKey: Constants.persistentOnboardingVariationsId) - - log.debug("Clear variationsIds for paywalls and onboarding.") - } -} - -extension VariationIdStorage { - nonisolated func getPaywallVariationIds(for productId: String) async -> (String?, String?) { - await (paywallVariationsIds[productId], persistentPaywallVariationsIds[productId]) - } - - nonisolated func getVariationIds(for productId: String) async -> (String?, String?, String?) { - await (paywallVariationsIds[productId], persistentPaywallVariationsIds[productId], persistentOnboardingVariationsId) - } - - nonisolated func getOnboardingVariationId() async -> String? { - await persistentOnboardingVariationsId - } - - nonisolated func setPaywallVariationIds(_ variationId: String?, for productId: String) async { - guard let variationId else { return } - Task { - if await setPaywallVariationId(variationId, for: productId) { - await Adapty.trackSystemEvent(AdaptyInternalEventParameters( - eventName: "did_set_variations_ids", - params: [ - "variation_by_product": paywallVariationsIds, - ] - )) - } - - if await setPersistentPaywallVariationId(variationId, for: productId) { - await Adapty.trackSystemEvent(AdaptyInternalEventParameters( - eventName: "did_set_variations_ids_persistent", - params: [ - "variation_by_product": persistentPaywallVariationsIds, - ] - )) - } - } - } - - nonisolated func setOnboardingVariationId(_ variationId: String?) async { - guard let variationId else { return } - Task { - if await setPersistentOnboardingVariationId(variationId) { - await Adapty.trackSystemEvent(AdaptyInternalEventParameters( - eventName: "did_set_onboarding_variations_id", - params: [ - "onboarding_variation_id": variationId, - ] - )) - } - } - } - - nonisolated func removePaywallVariationIds(for productId: String) { - Task { - guard await removePaywallVariationId(for: productId) else { return } - - await Adapty.trackSystemEvent(AdaptyInternalEventParameters( - eventName: "did_set_variations_ids", - params: [ - "variation_by_product": paywallVariationsIds, - ] - )) - } - } -} diff --git a/Sources/StoreKit/StoreKitManagerError.swift b/Sources/StoreKit/StoreKitManagerError.swift index f1d2ecca4..277345686 100644 --- a/Sources/StoreKit/StoreKitManagerError.swift +++ b/Sources/StoreKit/StoreKitManagerError.swift @@ -14,9 +14,11 @@ enum StoreKitManagerError: Error { case refreshReceiptFailed(AdaptyError.Source, error: Error) case requestSKProductsFailed(AdaptyError.Source, error: Error) case productPurchaseFailed(AdaptyError.Source, transactionError: Error?) + case unknownTransactionId(AdaptyError.Source) case transactionUnverified(AdaptyError.Source, error: Error?) case invalidOffer(AdaptyError.Source, error: String) case getSubscriptionInfoStatusFailed(AdaptyError.Source, error: Error) + case paymentPendingError(AdaptyError.Source) } extension StoreKitManagerError: CustomStringConvertible { @@ -42,6 +44,8 @@ extension StoreKitManagerError: CustomStringConvertible { } else { "StoreKitManagerError.productPurchaseFailed(\(source))" } + case let .unknownTransactionId(source): + "StoreKitManagerError.unknownTransactionId(\(source))" case let .transactionUnverified(source, error): if let error { "StoreKitManagerError.transactionUnverified(\(source), \(error))" @@ -52,6 +56,8 @@ extension StoreKitManagerError: CustomStringConvertible { "StoreKitManagerError.invalidOffer(\(source), \"\(error)\")" case let .getSubscriptionInfoStatusFailed(source, error): "StoreKitManagerError.getSubscriptionInfoStatusFailed(\(source), \(error))" + case let .paymentPendingError(source): + "StoreKitManagerError.paymentPendingError(\(source))" } } } @@ -65,9 +71,11 @@ extension StoreKitManagerError { let .refreshReceiptFailed(src, _), let .requestSKProductsFailed(src, _), let .interrupted(src), + let .unknownTransactionId(src), let .transactionUnverified(src, _), let .invalidOffer(src, _), - let .getSubscriptionInfoStatusFailed(src, _): src + let .getSubscriptionInfoStatusFailed(src, _), + let .paymentPendingError(src): src } } @@ -107,6 +115,14 @@ extension StoreKitManagerError { .productPurchaseFailed(AdaptyError.Source(file: file, function: function, line: line), transactionError: error) } + static func paymentPendingError( + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) -> Self { + .paymentPendingError(AdaptyError.Source(file: file, function: function, line: line)) + } + static func receiptIsEmpty( _ error: Error? = nil, file: String = #fileID, @@ -160,6 +176,14 @@ extension StoreKitManagerError { .interrupted(AdaptyError.Source(file: file, function: function, line: line)) } + static func unknownTransactionId( + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) -> Self { + .unknownTransactionId(AdaptyError.Source(file: file, function: function, line: line)) + } + static func transactionUnverified( _ error: Error, file: String = #fileID, diff --git a/Sources/StoreKit/StoreKitProductsManager.swift b/Sources/StoreKit/StoreKitProductsManager.swift index 1e6bb8dd5..e26c4dda8 100644 --- a/Sources/StoreKit/StoreKitProductsManager.swift +++ b/Sources/StoreKit/StoreKitProductsManager.swift @@ -8,18 +8,19 @@ import StoreKit protocol StoreKitProductsManager: Actor, Sendable { - func fillPurchasedTransaction( - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk1Transaction: SK1TransactionWithIdentifier - ) async -> PurchasedTransaction + func storeProductInfo(productInfo: [BackendProductInfo]) async + func fetchProduct(id productId: String, fetchPolicy: ProductsFetchPolicy) async throws(AdaptyError) -> AdaptyProduct? +} + +extension SK1ProductsManager: StoreKitProductsManager { + func fetchProduct(id productId: String, fetchPolicy: ProductsFetchPolicy) async throws(AdaptyError) -> AdaptyProduct? { + try await fetchSK1Product(id: productId, fetchPolicy: fetchPolicy).asAdaptyProduct + } +} - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) - func fillPurchasedTransaction( - paywallVariationId: String?, - persistentPaywallVariationId: String?, - persistentOnboardingVariationId: String?, - sk2Transaction: SK2Transaction - ) async -> PurchasedTransaction +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2ProductsManager: StoreKitProductsManager { + func fetchProduct(id productId: String, fetchPolicy: ProductsFetchPolicy) async throws(AdaptyError) -> AdaptyProduct? { + try await fetchSK2Product(id: productId, fetchPolicy: fetchPolicy).asAdaptyProduct + } } diff --git a/Sources/StoreKit/StoreKitReceiptManager.swift b/Sources/StoreKit/StoreKitReceiptManager.swift index 3de1cf32c..87d3583cf 100644 --- a/Sources/StoreKit/StoreKitReceiptManager.swift +++ b/Sources/StoreKit/StoreKitReceiptManager.swift @@ -10,12 +10,12 @@ import StoreKit private let log = Log.skReceiptManager actor StoreKitReceiptManager { - private let session: Backend.MainExecutor + let httpSession: Backend.MainExecutor private let refresher = ReceiptRefresher() - private var syncing: (task: AdaptyResultTask?>, profileId: String)? + private var syncing: (task: AdaptyResultTask, userId: AdaptyUserId)? - init(session: Backend.MainExecutor, refreshIfEmpty: Bool = false) { - self.session = session + init(httpSession: Backend.MainExecutor, refreshIfEmpty: Bool) { + self.httpSession = httpSession if refreshIfEmpty { Task { @@ -72,37 +72,37 @@ actor StoreKitReceiptManager { } } -extension StoreKitReceiptManager: StoreKitTransactionManager { - func syncTransactions(for profileId: String) async throws(AdaptyError) -> VH? { - let task: AdaptyResultTask?> - if let syncing, syncing.profileId == profileId { +extension StoreKitReceiptManager { + + + func syncTransactionHistory(for userId: AdaptyUserId) async throws(AdaptyError) { + let task: AdaptyResultTask + if let syncing, userId.isEqualProfileId(syncing.userId) { task = syncing.task } else { task = Task { + defer { syncing = nil } + let receipt: Data do throws(AdaptyError) { - let value = try await syncReceipt(for: profileId) - return .success(value) + receipt = try await getReceipt() } catch { return .failure(error) } - } - syncing = (task, profileId) - } - return try await task.value.get() - } - - private func syncReceipt(for profileId: String) async throws(AdaptyError) -> VH? { - defer { syncing = nil } - let receipt = try await getReceipt() - do { - return try await session.validateReceipt( - profileId: profileId, - receipt: receipt - ) - } catch { - throw error.asAdaptyError + do throws(HTTPError) { + let response = try await httpSession.validateReceipt( + userId: userId, + receipt: receipt + ) + await Adapty.optionalSDK?.handleTransactionResponse(response) + return .success(()) + } catch { + return .failure(error.asAdaptyError) + } + } + syncing = (task, userId) } + try await task.value.get() } } @@ -159,7 +159,7 @@ private final class ReceiptRefresher: NSObject, @unchecked Sendable { queue.async { [weak self] in guard let self else { return } - guard let handlers = self.refreshCompletionHandlers, !handlers.isEmpty else { + guard let handlers = self.refreshCompletionHandlers.nonEmptyOrNil else { log.error("Not found refreshCompletionHandlers") return } diff --git a/Sources/StoreKit/StoreKitSubscriptionOfferSigner.swift b/Sources/StoreKit/StoreKitSubscriptionOfferSigner.swift new file mode 100644 index 000000000..23f4d85d3 --- /dev/null +++ b/Sources/StoreKit/StoreKitSubscriptionOfferSigner.swift @@ -0,0 +1,35 @@ +// +// StoreKitSubscriptionOfferSigner.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 14.08.2025. +// + +import Foundation + +protocol StoreKitSubscriptionOfferSigner: AnyObject, Sendable { + func sign( + offerId: String, + subscriptionVendorId: String, + for userId: AdaptyUserId + ) async throws(AdaptyError) -> AdaptySubscriptionOffer.Signature +} + +extension Adapty: StoreKitSubscriptionOfferSigner { + func sign( + offerId: String, + subscriptionVendorId: String, + for userId: AdaptyUserId + ) async throws(AdaptyError) -> AdaptySubscriptionOffer.Signature { + do { + let response = try await httpSession.signSubscriptionOffer( + userId: userId, + vendorProductId: subscriptionVendorId, + offerId: offerId + ) + return response + } catch { + throw error.asAdaptyError + } + } +} diff --git a/Sources/StoreKit/StoreKitTransactionManager.swift b/Sources/StoreKit/StoreKitTransactionManager.swift index 3231fd645..0064e979f 100644 --- a/Sources/StoreKit/StoreKitTransactionManager.swift +++ b/Sources/StoreKit/StoreKitTransactionManager.swift @@ -8,5 +8,16 @@ import StoreKit protocol StoreKitTransactionManager: Actor, Sendable { - func syncTransactions(for profileId: String) async throws(AdaptyError) -> VH? + + func syncTransactionHistory(for userId: AdaptyUserId) async throws(AdaptyError) + func syncUnfinishedTransactions() async throws(AdaptyError) +} + +extension StoreKitReceiptManager: StoreKitTransactionManager { + func syncUnfinishedTransactions() async throws(AdaptyError) {} } + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2TransactionManager: StoreKitTransactionManager {} + + diff --git a/Sources/StoreKit/StoreKitTransactionSynchronizer.swift b/Sources/StoreKit/StoreKitTransactionSynchronizer.swift new file mode 100644 index 000000000..6f9e5e58a --- /dev/null +++ b/Sources/StoreKit/StoreKitTransactionSynchronizer.swift @@ -0,0 +1,110 @@ +// +// StoreKitTransactionSynchronizer.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 04.10.2024 +// + +import Foundation + +protocol StoreKitTransactionSynchronizer: AnyObject, Sendable { + func report( + _: PurchasedTransactionInfo, + payload: PurchasePayload, + reason: Adapty.ValidatePurchaseReason + ) async throws(AdaptyError) + + func validate( + _: PurchasedTransactionInfo, + payload: PurchasePayload + ) async throws(AdaptyError) -> AdaptyProfile + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func finish( + transaction: SK2Transaction, + recived: TransactionRecivedBy + ) async + + func recalculateOfflineAccessLevels(with: SKTransaction) async -> AdaptyProfile? +} + +enum TransactionRecivedBy { + case updates + case purchased + case unfinished + case manual +} + +extension Adapty: StoreKitTransactionSynchronizer { + enum ValidatePurchaseReason: Sendable, Hashable { + case setVariation + case observing + case purchasing + case unfinished + } + + func sendTransactionId( + _ transactionId: String, + with variationId: String?, + for userId: AdaptyUserId + ) async throws(AdaptyError) { + do { + let response = try await httpSession.sendTransactionId( + transactionId, + with: variationId, + for: userId + ) + handleProfileResponse(response) + } catch { + throw error.asAdaptyError + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + func finish( + transaction: SK2Transaction, + recived _: TransactionRecivedBy + ) async { + await transaction.finish() + await purchasePayloadStorage.removePurchasePayload(forTransaction: transaction) + await purchasePayloadStorage.removeUnfinishedTransaction(transaction.unfIdentifier) + Adapty.trackSystemEvent(AdaptyAppleRequestParameters( + methodName: .finishTransaction, + params: transaction.logParams + )) + } + + func report( + _ transactionInfo: PurchasedTransactionInfo, + payload: PurchasePayload, + reason: Adapty.ValidatePurchaseReason + ) async throws(AdaptyError) { + do { + let response = try await httpSession.validateTransaction( + transactionInfo: transactionInfo, + payload: payload, + reason: reason + ) + handleTransactionResponse(response) + } catch { + throw error.asAdaptyError + } + } + + func validate( + _ transactionInfo: PurchasedTransactionInfo, + payload: PurchasePayload + ) async throws(AdaptyError) -> AdaptyProfile { + do { + let response = try await httpSession.validateTransaction( + transactionInfo: transactionInfo, + payload: payload, + reason: .purchasing + ) + handleTransactionResponse(response) + return await profileWithOfflineAccessLevels(response.value) + } catch { + throw error.asAdaptyError + } + } +} diff --git a/Sources/StoreKit/StorekitPurchaser.swift b/Sources/StoreKit/StorekitPurchaser.swift new file mode 100644 index 000000000..2b31024f3 --- /dev/null +++ b/Sources/StoreKit/StorekitPurchaser.swift @@ -0,0 +1,21 @@ +// +// StorekitPurchaser.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 06.08.2025. +// + +import StoreKit + +protocol StorekitPurchaser: Actor, Sendable { + func makePurchase( + userId: AdaptyUserId, + appAccountToken: UUID?, + product: AdaptyPaywallProduct + ) async throws(AdaptyError) -> AdaptyPurchaseResult +} + +extension SK1QueueManager: StorekitPurchaser {} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2Purchaser: StorekitPurchaser {} diff --git a/Sources/UserAcquisition/Storage/UserAcquisitionStorage.swift b/Sources/UserAcquisition/Storage/UserAcquisitionStorage.swift index 35208339f..72f86f233 100644 --- a/Sources/UserAcquisition/Storage/UserAcquisitionStorage.swift +++ b/Sources/UserAcquisition/Storage/UserAcquisitionStorage.swift @@ -10,7 +10,7 @@ import Foundation private let log = Log.storage @AdaptyActor -final class UserAcquisitionStorage: Sendable { +final class UserAcquisitionStorage { private enum Constants { static let version = "AdaptySDK_User_Acquisition_Version" static let registrationInstallSaved = "AdaptySDK_User_Acquisition_saved" diff --git a/Sources/UserAcquisition/UserAcquisitionManager.swift b/Sources/UserAcquisition/UserAcquisitionManager.swift index b71333e3c..58afaca07 100644 --- a/Sources/UserAcquisition/UserAcquisitionManager.swift +++ b/Sources/UserAcquisition/UserAcquisitionManager.swift @@ -8,7 +8,7 @@ import Foundation @AdaptyActor -final class UserAcquisitionManager: Sendable { +final class UserAcquisitionManager { private let storage: UserAcquisitionStorage private let executor: Backend.UAExecutor private let installTime: Date @@ -62,7 +62,7 @@ final class UserAcquisitionManager: Sendable { do throws(HTTPError) { let response = try await executor.registerInstall( - profileId: sdk.profileStorage.profileId, + userId: sdk.profileStorage.userId, installInfo: installInfo, maxRetries: maxRetries ) diff --git a/Sources/Versions.swift b/Sources/Versions.swift index 610130397..07c289e88 100644 --- a/Sources/Versions.swift +++ b/Sources/Versions.swift @@ -8,8 +8,8 @@ import Foundation extension Adapty { - public nonisolated static let SDKVersion = "3.11.0" - nonisolated static let fallbackFormatVersion = 8 + public nonisolated static let SDKVersion = "3.12.0" + nonisolated static let fallbackFormatVersion = 9 nonisolated static let userAcquisitionVersion = 1 } diff --git a/Sources/WebPaywall/Adapty+WebPaywall.swift b/Sources/WebPaywall/Adapty+WebPaywall.swift index 0eb15d3c2..e9146aeec 100644 --- a/Sources/WebPaywall/Adapty+WebPaywall.swift +++ b/Sources/WebPaywall/Adapty+WebPaywall.swift @@ -83,7 +83,7 @@ public extension Adapty { ) async throws(AdaptyError) { let url = try createWebPaywallUrl(for: product) guard await url.open() else { - throw AdaptyError.failedOpeningWebPaywallUrl(url) + throw .failedOpeningWebPaywallUrl(url) } profileStorage.setLastOpenedWebPaywallDate() } @@ -93,7 +93,7 @@ public extension Adapty { ) async throws(AdaptyError) { let url = try createWebPaywallUrl(for: paywall) guard await url.open() else { - throw AdaptyError.failedOpeningWebPaywallUrl(url) + throw .failedOpeningWebPaywallUrl(url) } profileStorage.setLastOpenedWebPaywallDate() } @@ -102,7 +102,7 @@ public extension Adapty { for paywall: AdaptyPaywall ) throws(AdaptyError) -> URL { guard let webPaywallBaseUrl = paywall.webPaywallBaseUrl else { - throw AdaptyError.paywallWithoutPurchaseUrl(paywall: paywall) + throw .paywallWithoutPurchaseUrl(paywall: paywall) } let parameters = [ @@ -117,7 +117,7 @@ public extension Adapty { for product: AdaptyPaywallProduct ) throws(AdaptyError) -> URL { guard let webPaywallBaseUrl = (product as? WebPaywallURLProviding)?.webPaywallBaseUrl else { - throw AdaptyError.productWithoutPurchaseUrl(adaptyProductId: product.adaptyProductId) + throw .productWithoutPurchaseUrl(adaptyProductId: product.adaptyProductId) } var parameters = [ @@ -129,7 +129,7 @@ public extension Adapty { ] if let offer = product.subscriptionOffer { - parameters["adapty_offer_category"] = offer.offerType.encodedValue + parameters["adapty_offer_category"] = offer.offerType.rawValue parameters["adapty_offer_type"] = offer.paymentMode.encodedValue ?? "unknown" let period = offer.subscriptionPeriod parameters["adapty_offer_period_units"] = period.unit.encodedValue @@ -154,7 +154,7 @@ private extension URL { _ parameters: [String: String] ) throws(AdaptyError) -> URL { guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { - throw AdaptyError.failedDecodingWebPaywallUrl(url: self) + throw .failedDecodingWebPaywallUrl(url: self) } var existingParams = components.queryItems?.reduce(into: [String: String]()) { @@ -168,7 +168,7 @@ private extension URL { components.queryItems = existingParams.map { URLQueryItem(name: $0.key, value: $0.value) } guard let url = components.url else { - throw AdaptyError.failedDecodingWebPaywallUrl(url: self) + throw .failedDecodingWebPaywallUrl(url: self) } return url } diff --git a/Sources/_Lib/Emptiable/Collection+Emptable.swift b/Sources/_Lib/Emptiable/Collection+Emptable.swift new file mode 100644 index 000000000..41c8399f9 --- /dev/null +++ b/Sources/_Lib/Emptiable/Collection+Emptable.swift @@ -0,0 +1,22 @@ +// +// Collection+Emptable.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 27.07.2025. +// + +extension Collection { + @inlinable + var nonEmptyOrNil: Self? { isEmpty ? nil : self } + @inlinable + var isNotEmpty: Bool { !isEmpty } +} + +extension Optional where Wrapped: Collection { + @inlinable + var nonEmptyOrNil: Self { self?.nonEmptyOrNil } + @inlinable + var isEmpty: Bool { self?.isEmpty ?? true } + @inlinable + var isNotEmpty: Bool { !isEmpty } +} diff --git a/Sources/_Lib/Emptiable/Emptiable.swift b/Sources/_Lib/Emptiable/Emptiable.swift new file mode 100644 index 000000000..034366f74 --- /dev/null +++ b/Sources/_Lib/Emptiable/Emptiable.swift @@ -0,0 +1,23 @@ +// +// Emptiable.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 27.07.2025. +// + +protocol Emptiable { + var isEmpty: Bool { get } +} + +extension Emptiable { + @inlinable + var nonEmptyOrNil: Self? { isEmpty ? nil : self } + @inlinable + var isNotEmpty: Bool { !isEmpty } +} + +extension Optional where Wrapped: Emptiable { + var nonEmptyOrNil: Self { self?.nonEmptyOrNil } + var isEmpty: Bool { self?.isEmpty ?? true } + var isNotEmpty: Bool { !isEmpty } +} diff --git a/Sources/_Lib/Emptiable/String+Emptable.swift b/Sources/_Lib/Emptiable/String+Emptable.swift new file mode 100644 index 000000000..dee05951b --- /dev/null +++ b/Sources/_Lib/Emptiable/String+Emptable.swift @@ -0,0 +1,29 @@ +// +// String+Emptable.swift +// AdaptySDK +// +// Created by Aleksei Valiano on 28.07.2025. +// + +extension StringProtocol { + @inlinable + var nonEmptyOrNil: Self? { isEmpty ? nil : self } + @inlinable + var isNotEmpty: Bool { !isEmpty } +} + +extension Optional where Wrapped: StringProtocol { + @inlinable + var nonEmptyOrNil: Self { self?.nonEmptyOrNil } + @inlinable + var isEmpty: Bool { self?.isEmpty ?? true } + @inlinable + var isNotEmpty: Bool { !isEmpty } + var trimmed: String? { self?.trimmed } +} + +extension StringProtocol { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/_Lib/Optional/Optional+Extensions.swift b/Sources/_Lib/Optional/Optional+Extensions.swift deleted file mode 100644 index 8be4a4304..000000000 --- a/Sources/_Lib/Optional/Optional+Extensions.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Optional+Extensions.swift -// AdaptySDK -// -// Created by Aleksei Valiano on 05.10.2024 -// - -import Foundation - -extension Optional where Wrapped: Equatable { - func nonOptionalIsEqual(_ other: Wrapped?) -> Bool { - guard case let .some(wrapped) = self, let other else { - return false - } - return wrapped == other - } -}