Files
purchases-ios/Sources/Identity/CustomerInfoManager.swift
T
Antonio Pallares 7683d522fa UI Preview Mode: mock CustomerInfo (#4786)
* feat: mock `CustomerInfo` when in UI Preview mode and avoid caching it

* feat: return mock `CustomerInfo` when in UI Preview mode when requesting the cached `CustomerInfo`

* tests: add unit test for `CustomerInfo` mock in UI Preview mode

* fix linter complaints
2025-02-14 16:34:59 +01:00

519 lines
20 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CustomerInfoManager.swift
//
// Created by Joshua Liebowitz on 8/5/21.
import Foundation
// swiftlint:disable file_length
class CustomerInfoManager {
typealias CustomerInfoCompletion = @MainActor @Sendable (Result<CustomerInfo, BackendError>) -> Void
private let offlineEntitlementsManager: OfflineEntitlementsManager
private let operationDispatcher: OperationDispatcher
private let backend: Backend
private let systemInfo: SystemInfo
private let transactionFetcher: StoreKit2TransactionFetcherType
private let transactionPoster: TransactionPosterType
private var diagnosticsTracker: DiagnosticsTrackerType?
/// Underlying synchronized data.
private let data: Atomic<Data>
init(offlineEntitlementsManager: OfflineEntitlementsManager,
operationDispatcher: OperationDispatcher,
deviceCache: DeviceCache,
backend: Backend,
transactionFetcher: StoreKit2TransactionFetcherType,
transactionPoster: TransactionPosterType,
systemInfo: SystemInfo
) {
self.offlineEntitlementsManager = offlineEntitlementsManager
self.operationDispatcher = operationDispatcher
self.backend = backend
self.transactionFetcher = transactionFetcher
self.transactionPoster = transactionPoster
self.systemInfo = systemInfo
self.data = .init(.init(deviceCache: deviceCache))
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
convenience init(offlineEntitlementsManager: OfflineEntitlementsManager,
operationDispatcher: OperationDispatcher,
deviceCache: DeviceCache,
backend: Backend,
transactionFetcher: StoreKit2TransactionFetcherType,
transactionPoster: TransactionPosterType,
systemInfo: SystemInfo,
diagnosticsTracker: DiagnosticsTrackerType?
) {
self.init(offlineEntitlementsManager: offlineEntitlementsManager,
operationDispatcher: operationDispatcher,
deviceCache: deviceCache,
backend: backend,
transactionFetcher: transactionFetcher,
transactionPoster: transactionPoster,
systemInfo: systemInfo)
self.diagnosticsTracker = diagnosticsTracker
}
func fetchAndCacheCustomerInfo(appUserID: String,
isAppBackgrounded: Bool,
completion: CustomerInfoCompletion?) {
self.getCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded) { result in
switch result {
case let .failure(error):
self.withData { $0.deviceCache.clearCustomerInfoCacheTimestamp(appUserID: appUserID) }
Logger.warn(Strings.customerInfo.customerinfo_updated_from_network_error(error))
case let .success(info):
self.cache(customerInfo: info, appUserID: appUserID)
Logger.rcSuccess(
info.isComputedOffline
? Strings.customerInfo.customerinfo_updated_offline
: Strings.customerInfo.customerinfo_updated_from_network
)
}
if let completion = completion {
self.operationDispatcher.dispatchOnMainActor {
completion(result)
}
}
}
}
func fetchAndCacheCustomerInfoIfStale(appUserID: String,
isAppBackgrounded: Bool,
completion: CustomerInfoCompletion?) {
let isCacheStale = self.withData {
$0.deviceCache.isCustomerInfoCacheStale(appUserID: appUserID, isAppBackgrounded: isAppBackgrounded)
}
guard !isCacheStale, let customerInfo = self.cachedCustomerInfo(appUserID: appUserID) else {
Logger.debug(isAppBackgrounded
? Strings.customerInfo.customerinfo_stale_updating_in_background
: Strings.customerInfo.customerinfo_stale_updating_in_foreground)
self.fetchAndCacheCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
return
}
if let completion = completion {
self.operationDispatcher.dispatchOnMainActor {
completion(.success(customerInfo))
}
}
}
// swiftlint:disable:next function_body_length
func customerInfo(
appUserID: String,
fetchPolicy: CacheFetchPolicy,
completion: CustomerInfoCompletion?
) {
switch fetchPolicy {
case .fromCacheOnly:
self.operationDispatcher.dispatchOnMainActor {
completion?(
Result(self.cachedCustomerInfo(appUserID: appUserID), .missingCachedCustomerInfo())
)
}
case .fetchCurrent:
self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in
self.fetchAndCacheCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
}
case .cachedOrFetched:
let infoFromCache = self.cachedCustomerInfo(appUserID: appUserID)
var completionCalled = false
if let infoFromCache = infoFromCache {
Logger.debug(Strings.customerInfo.vending_cache)
if let completion = completion {
completionCalled = true
self.operationDispatcher.dispatchOnMainActor {
completion(.success(infoFromCache))
}
}
}
// Prevent calling completion twice.
let completionIfNotCalledAlready = completionCalled ? nil : completion
self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in
self.fetchAndCacheCustomerInfoIfStale(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completionIfNotCalledAlready)
}
case .notStaleCachedOrFetched:
let infoFromCache = self.cachedCustomerInfo(appUserID: appUserID)
self.systemInfo.isApplicationBackgrounded { isAppBackgrounded in
let isCacheStale = self.withData {
$0.deviceCache.isCustomerInfoCacheStale(appUserID: appUserID, isAppBackgrounded: isAppBackgrounded)
}
if let infoFromCache = infoFromCache, !isCacheStale {
Logger.debug(Strings.customerInfo.vending_cache)
if let completion = completion {
self.operationDispatcher.dispatchOnMainActor {
completion(.success(infoFromCache))
}
}
} else {
self.fetchAndCacheCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
}
}
}
}
func cachedCustomerInfo(appUserID: String) -> CustomerInfo? {
guard !self.systemInfo.dangerousSettings.uiPreviewMode else {
return self.createPreviewCustomerInfo()
}
let cachedCustomerInfoData = self.withData {
$0.deviceCache.cachedCustomerInfoData(appUserID: appUserID)
}
guard let customerInfoData = cachedCustomerInfoData else { return nil }
do {
let info: CustomerInfo = try JSONDecoder.default.decode(jsonData: customerInfoData)
if info.schemaVersionIsCompatible {
return info
} else {
return nil
}
} catch {
Logger.error("Error loading customer info from cache:\n \(error.localizedDescription)")
return nil
}
}
func cache(customerInfo: CustomerInfo, appUserID: String) {
guard !self.systemInfo.dangerousSettings.uiPreviewMode else {
return
}
if customerInfo.shouldCache {
do {
let jsonData = try JSONEncoder.default.encode(customerInfo)
self.withData { $0.deviceCache.cache(customerInfo: jsonData, appUserID: appUserID) }
} catch {
Logger.error(Strings.customerInfo.error_encoding_customerinfo(error))
}
} else {
Logger.debug(Strings.customerInfo.not_caching_offline_customer_info)
self.clearCustomerInfoCache(forAppUserID: appUserID)
}
self.sendUpdateIfChanged(customerInfo: customerInfo)
}
func clearCustomerInfoCache(forAppUserID appUserID: String) {
self.modifyData {
$0.deviceCache.clearCustomerInfoCache(appUserID: appUserID)
}
}
func setLastSentCustomerInfo(_ info: CustomerInfo) {
self.modifyData {
$0.lastSentCustomerInfo = info
}
}
var customerInfoStream: AsyncStream<CustomerInfo> {
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
if let lastSentCustomerInfo = self.lastSentCustomerInfo {
continuation.yield(lastSentCustomerInfo)
}
let disposable = self.monitorChanges { _, new in continuation.yield(new) }
continuation.onTermination = { @Sendable _ in disposable() }
}
}
typealias CustomerInfoChangeClosure = (_ old: CustomerInfo?, _ new: CustomerInfo) -> Void
/// Allows monitoring changes to the active `CustomerInfo`.
/// - Returns: closure that removes the created observation.
func monitorChanges(_ changes: @escaping CustomerInfoChangeClosure) -> () -> Void {
self.modifyData {
let lastIdentifier = $0.customerInfoObserversByIdentifier.keys
.sorted()
.last
let nextIdentifier = lastIdentifier
.map { $0 + 1 } // Next index
?? 0 // Or default to 0
$0.customerInfoObserversByIdentifier[nextIdentifier] = changes
return { [weak self] in
self?.removeObserver(with: nextIdentifier)
}
}
}
// Visible for tests
var lastSentCustomerInfo: CustomerInfo? { return self.data.value.lastSentCustomerInfo }
private func removeObserver(with identifier: Int) {
self.modifyData {
$0.customerInfoObserversByIdentifier.removeValue(forKey: identifier)
}
}
private func sendUpdateIfChanged(customerInfo: CustomerInfo) {
return self.modifyData {
let lastSentCustomerInfo = $0.lastSentCustomerInfo
if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) {
if let tracker = self.diagnosticsTracker, lastSentCustomerInfo != customerInfo {
tracker.trackCustomerInfoVerificationResultIfNeeded(customerInfo)
}
}
guard !$0.customerInfoObserversByIdentifier.isEmpty, lastSentCustomerInfo != customerInfo else {
return
}
if $0.lastSentCustomerInfo != nil {
Logger.debug(Strings.customerInfo.sending_updated_customerinfo_to_delegate)
} else {
Logger.debug(Strings.customerInfo.sending_latest_customerinfo_to_delegate)
}
$0.lastSentCustomerInfo = customerInfo
// This must be async to prevent deadlocks if the observer calls a method that ends up reading
// this class' data. By making it async, the closure is invoked outside of the lock.
self.operationDispatcher.dispatchAsyncOnMainThread { [observers = $0.customerInfoObserversByIdentifier] in
for closure in observers.values {
closure(lastSentCustomerInfo, customerInfo)
}
}
}
}
}
// MARK: - async extensions
extension CustomerInfoManager {
func fetchAndCacheCustomerInfo(appUserID: String, isAppBackgrounded: Bool) async throws -> CustomerInfo {
return try await Async.call { completion in
return self.fetchAndCacheCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
}
}
func customerInfo(
appUserID: String,
fetchPolicy: CacheFetchPolicy
) async throws -> CustomerInfo {
return try await Async.call { completion in
return self.customerInfo(appUserID: appUserID,
fetchPolicy: fetchPolicy,
completion: completion)
}
}
}
// MARK: -
private extension CustomerInfoManager {
func getCustomerInfo(appUserID: String,
isAppBackgrounded: Bool,
completion: @escaping CustomerAPI.CustomerInfoResponseHandler) {
guard !self.systemInfo.dangerousSettings.uiPreviewMode else {
completion(.success(self.createPreviewCustomerInfo()))
return
}
if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) {
_ = Task<Void, Never> {
let transactions = await self.transactionFetcher.unfinishedVerifiedTransactions
if let transactionToPost = transactions.first {
Logger.debug(
Strings.customerInfo.posting_transactions_in_lieu_of_fetching_customerinfo(transactions)
)
let transactionData = PurchasedTransactionData(
appUserID: appUserID,
presentedOfferingContext: nil,
unsyncedAttributes: [:],
storefront: await Storefront.currentStorefront,
source: Self.sourceForUnfinishedTransaction
)
// Post everything but the first transaction in the background
// in parallel so they can be de-duped
let otherTransactionsToPostInParalel = Array(transactions.dropFirst())
Task.detached(priority: .background) {
await self.postTransactions(otherTransactionsToPostInParalel, transactionData)
}
// Return the result of posting the first transaction.
// The posted receipt will include the content of every other transaction
// so we don't need to wait for those.
completion(await self.transactionPoster.handlePurchasedTransaction(
transactionToPost,
data: transactionData
))
} else {
self.requestCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
}
}
} else {
return self.requestCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
}
}
private func requestCustomerInfo(appUserID: String,
isAppBackgrounded: Bool,
completion: @escaping CustomerAPI.CustomerInfoResponseHandler) {
let allowComputingOffline = self.offlineEntitlementsManager.shouldComputeOfflineCustomerInfo(
appUserID: appUserID
)
self.backend.getCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
allowComputingOffline: allowComputingOffline,
completion: completion)
}
/// Posts all `transactions` in parallel.
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
private func postTransactions(
_ transactions: [StoreTransaction],
_ data: PurchasedTransactionData
) async {
await withTaskGroup(of: Void.self) { group in
for transaction in transactions {
group.addTask {
_ = await self.transactionPoster.handlePurchasedTransaction(
transaction,
data: data
)
}
}
}
}
// Note: this is just a best guess.
private static let sourceForUnfinishedTransaction: PurchaseSource = .init(
isRestore: false,
// This might have been in theory a `.purchase`. The only downside of this is that the server
// won't validate that the product is present in the receipt.
initiationSource: .queue
)
// MARK: - For UI Preview mode
/// Generates a dummy `CustomerInfo` with hardcoded information exclusively for UI Preview mode.
private func createPreviewCustomerInfo() -> CustomerInfo {
let previewSubscriber = CustomerInfoResponse.Subscriber(
originalAppUserId: IdentityManager.uiPreviewModeAppUserID,
firstSeen: Date(),
subscriptions: [:],
nonSubscriptions: [:],
entitlements: [:]
)
let previewCustomerInfoResponse = CustomerInfoResponse(subscriber: previewSubscriber,
requestDate: Date(),
rawData: [:])
let previewCustomerInfo = CustomerInfo(response: previewCustomerInfoResponse,
entitlementVerification: .verified,
sandboxEnvironmentDetector: BundleSandboxEnvironmentDetector.default)
return previewCustomerInfo
}
}
// @unchecked because:
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension CustomerInfoManager: @unchecked Sendable {}
// MARK: -
private extension CustomerInfoManager {
/// Underlying data for `CustomerInfoManager`.
struct Data {
let deviceCache: DeviceCache
var lastSentCustomerInfo: CustomerInfo?
/// Observers keyed by a monotonically increasing identifier.
/// This allows cancelling observations by deleting them from this dictionary.
/// These observers are used both for ``Purchases/customerInfoStream`` and
/// `PurchasesDelegate/purchases(_:receivedUpdated:)``.
var customerInfoObserversByIdentifier: [Int: CustomerInfoManager.CustomerInfoChangeClosure]
init(deviceCache: DeviceCache) {
self.deviceCache = deviceCache
self.lastSentCustomerInfo = nil
self.customerInfoObserversByIdentifier = [:]
}
}
func withData<Result>(_ action: (Data) -> Result) -> Result {
return self.data.withValue(action)
}
@discardableResult
func modifyData<Result>(_ action: (inout Data) -> Result) -> Result {
return self.data.modify(action)
}
}
private extension CustomerInfo {
var shouldCache: Bool {
return self.entitlements.verification.shouldCache
}
}
private extension VerificationResult {
var shouldCache: Bool {
switch self {
case .failed, .verified, .notRequested: return true
case .verifiedOnDevice: return false
}
}
}