Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Offline Entitlements: use offline-computed CustomerInfo when server is down #2368

Merged
merged 18 commits into from
May 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion RevenueCat.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Sources/Caching/DeviceCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,16 @@ class DeviceCache {
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension DeviceCache: @unchecked Sendable {}

// MARK: -

extension DeviceCache: ProductEntitlementMappingFetcher {

var productEntitlementMapping: ProductEntitlementMapping? {
return self.cachedProductEntitlementMapping
}

}

// MARK: - Private

// All methods that modify or read from the UserDefaults data source but require external mechanisms for ensuring
Expand Down
5 changes: 5 additions & 0 deletions Sources/Error Handling/BackendError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ extension BackendError {
return self.networkError?.finishable ?? false
}

/// Whether the error represents a `NetworkError` from the server being down.
var isServerDown: Bool {
return self.networkError?.isServerDown == true
}

private var networkError: NetworkError? {
switch self {
case let .networkError(networkError):
Expand Down
19 changes: 16 additions & 3 deletions Sources/Identity/CustomerInfoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@ class CustomerInfoManager {

var lastSentCustomerInfo: CustomerInfo? { return self.data.value.lastSentCustomerInfo }

private let offlineEntitlementsManager: OfflineEntitlementsManager
private let operationDispatcher: OperationDispatcher
private let backend: Backend
private let systemInfo: SystemInfo
/// Underlying synchronized data.
private let data: Atomic<Data>

init(operationDispatcher: OperationDispatcher,
init(offlineEntitlementsManager: OfflineEntitlementsManager,
operationDispatcher: OperationDispatcher,
deviceCache: DeviceCache,
backend: Backend,
systemInfo: SystemInfo) {
self.offlineEntitlementsManager = offlineEntitlementsManager
self.operationDispatcher = operationDispatcher
self.backend = backend
self.systemInfo = systemInfo
Expand All @@ -38,16 +41,25 @@ class CustomerInfoManager {
func fetchAndCacheCustomerInfo(appUserID: String,
isAppBackgrounded: Bool,
completion: CustomerInfoCompletion?) {
let allowComputingOffline = self.offlineEntitlementsManager.shouldComputeOfflineCustomerInfo(
appUserID: appUserID
)

self.backend.getCustomerInfo(appUserID: appUserID,
withRandomDelay: isAppBackgrounded) { result in
withRandomDelay: isAppBackgrounded,
allowComputingOffline: allowComputingOffline) { 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(Strings.customerInfo.customerinfo_updated_from_network)
Logger.rcSuccess(
info.isComputedOffline
? Strings.customerInfo.customerinfo_updated_offline
: Strings.customerInfo.customerinfo_updated_from_network
)
}

if let completion = completion {
Expand Down Expand Up @@ -190,6 +202,7 @@ class CustomerInfoManager {
}
} else {
Logger.debug(Strings.customerInfo.not_caching_offline_customer_info)
self.clearCustomerInfoCache(forAppUserID: appUserID)
}

self.sendUpdateIfChanged(customerInfo: customerInfo)
Expand Down
3 changes: 3 additions & 0 deletions Sources/Logging/Strings/CustomerInfoStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum CustomerInfoStrings {
case customerinfo_stale_updating_in_foreground
case customerinfo_updated_from_network
case customerinfo_updated_from_network_error(BackendError)
case customerinfo_updated_offline
case updating_request_date(CustomerInfo, Date)
case sending_latest_customerinfo_to_delegate
case sending_updated_customerinfo_to_delegate
Expand Down Expand Up @@ -68,6 +69,8 @@ extension CustomerInfoStrings: CustomStringConvertible {
}

return result
case .customerinfo_updated_offline:
return "CustomerInfo computed offline."
case let .updating_request_date(info, newRequestDate):
return "Updating CustomerInfo '\(info.originalAppUserId)' request date: \(newRequestDate)"
case .sending_latest_customerinfo_to_delegate:
Expand Down
24 changes: 22 additions & 2 deletions Sources/Logging/Strings/OfflineEntitlementsStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,29 @@ import StoreKit

// swiftlint:disable identifier_name

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
enum OfflineEntitlementsStrings {

case offline_entitlements_not_available

case product_entitlement_mapping_stale_updating
case product_entitlement_mapping_updated_from_network
case product_entitlement_mapping_fetching_error(BackendError)
case found_unverified_transactions_in_sk2(transactionID: UInt64, Error)

case computing_offline_customer_info_with_no_entitlement_mapping
case computing_offline_customer_info
case computing_offline_customer_info_failed(Error)
case computed_offline_customer_info(EntitlementInfos)

}

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
extension OfflineEntitlementsStrings: CustomStringConvertible {

var description: String {
switch self {
case .offline_entitlements_not_available:
return "OS version does not support offline entitlements."

case .product_entitlement_mapping_stale_updating:
return "ProductEntitlementMapping cache is stale, updating from network."

Expand All @@ -48,6 +56,18 @@ extension OfflineEntitlementsStrings: CustomStringConvertible {
Transaction ID: \(transactionID)
"""

case .computing_offline_customer_info_with_no_entitlement_mapping:
return "Unable to compute offline CustomerInfo with no product entitlement mapping."

case .computing_offline_customer_info:
return "Encountered a server error. Will attempt to compute an offline CustomerInfo from local purchases."

case let .computing_offline_customer_info_failed(error):
return "Error computing offline CustomerInfo. Will return original error.\n" +
"Creation error: \(error.localizedDescription)"

case let .computed_offline_customer_info(entitlements):
return "Computed offline CustomerInfo with \(entitlements.active.count) active entitlements."
}
}

Expand Down
1 change: 0 additions & 1 deletion Sources/Logging/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ enum Strings {
static let identity = IdentityStrings.self
static let network = NetworkStrings.self
static let offering = OfferingStrings.self
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
static let offlineEntitlements = OfflineEntitlementsStrings.self
static let purchase = PurchaseStrings.self
static let receipt = ReceiptStrings.self
Expand Down
24 changes: 15 additions & 9 deletions Sources/Networking/Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,26 @@ class Backend {

private let config: BackendConfiguration

convenience init(apiKey: String,
systemInfo: SystemInfo,
httpClientTimeout: TimeInterval = Configuration.networkTimeoutDefault,
eTagManager: ETagManager,
operationDispatcher: OperationDispatcher,
attributionFetcher: AttributionFetcher,
dateProvider: DateProvider = DateProvider()) {
convenience init(
apiKey: String,
systemInfo: SystemInfo,
httpClientTimeout: TimeInterval = Configuration.networkTimeoutDefault,
eTagManager: ETagManager,
operationDispatcher: OperationDispatcher,
attributionFetcher: AttributionFetcher,
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?,
dateProvider: DateProvider = DateProvider()
) {
let httpClient = HTTPClient(apiKey: apiKey,
systemInfo: systemInfo,
eTagManager: eTagManager,
requestTimeout: httpClientTimeout)
let config = BackendConfiguration(httpClient: httpClient,
operationDispatcher: operationDispatcher,
operationQueue: QueueProvider.createBackendQueue(),
dateProvider: dateProvider,
systemInfo: systemInfo)
systemInfo: systemInfo,
offlineCustomerInfoCreator: offlineCustomerInfoCreator,
dateProvider: dateProvider)
self.init(backendConfig: config, attributionFetcher: attributionFetcher)
}

Expand Down Expand Up @@ -96,9 +100,11 @@ class Backend {

func getCustomerInfo(appUserID: String,
withRandomDelay randomDelay: Bool,
allowComputingOffline: Bool = true,
completion: @escaping CustomerAPI.CustomerInfoResponseHandler) {
self.customer.getCustomerInfo(appUserID: appUserID,
withRandomDelay: randomDelay,
allowComputingOffline: allowComputingOffline,
completion: completion)
}

Expand Down
10 changes: 7 additions & 3 deletions Sources/Networking/BackendConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,26 @@

import Foundation

final class BackendConfiguration {
class BackendConfiguration {

let httpClient: HTTPClient

let operationDispatcher: OperationDispatcher
let operationQueue: OperationQueue
let dateProvider: DateProvider
let systemInfo: SystemInfo
let offlineCustomerInfoCreator: OfflineCustomerInfoCreator?

init(httpClient: HTTPClient,
operationDispatcher: OperationDispatcher,
operationQueue: OperationQueue,
dateProvider: DateProvider = DateProvider(),
systemInfo: SystemInfo) {
systemInfo: SystemInfo,
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?,
dateProvider: DateProvider = DateProvider()) {
self.httpClient = httpClient
self.operationDispatcher = operationDispatcher
self.operationQueue = operationQueue
self.offlineCustomerInfoCreator = offlineCustomerInfoCreator
self.dateProvider = dateProvider
self.systemInfo = systemInfo
}
Expand Down Expand Up @@ -59,4 +62,5 @@ extension BackendConfiguration {

// @unchecked because:
// - `OperationQueue` is not `Sendable` as of Swift 5.7
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension BackendConfiguration: @unchecked Sendable {}
19 changes: 14 additions & 5 deletions Sources/Networking/CustomerAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@ final class CustomerAPI {

func getCustomerInfo(appUserID: String,
withRandomDelay randomDelay: Bool,
allowComputingOffline: Bool,
completion: @escaping CustomerInfoResponseHandler) {
let config = NetworkOperation.UserSpecificConfiguration(httpClient: self.backendConfig.httpClient,
appUserID: appUserID)

let factory = GetCustomerInfoOperation.createFactory(configuration: config,
customerInfoCallbackCache: self.customerInfoCallbackCache)
let factory = GetCustomerInfoOperation.createFactory(
configuration: config,
customerInfoCallbackCache: self.customerInfoCallbackCache,
offlineCreator: allowComputingOffline
? self.backendConfig.offlineCustomerInfoCreator
: nil
)

let callback = CustomerInfoCallback(cacheKey: factory.cacheKey,
source: factory.operationType,
Expand Down Expand Up @@ -113,9 +119,12 @@ final class CustomerAPI {
observerMode: observerMode,
initiationSource: initiationSource,
subscriberAttributesByKey: subscriberAttributesToPost)
let factory = PostReceiptDataOperation.createFactory(configuration: config,
postData: postData,
customerInfoCallbackCache: self.customerInfoCallbackCache)
let factory = PostReceiptDataOperation.createFactory(
configuration: config,
postData: postData,
customerInfoCallbackCache: self.customerInfoCallbackCache,
offlineCustomerInfoCreator: self.backendConfig.offlineCustomerInfoCreator
)

let callbackObject = CustomerInfoCallback(cacheKey: factory.cacheKey,
source: PostReceiptDataOperation.self,
Expand Down
16 changes: 15 additions & 1 deletion Sources/Networking/Operations/GetCustomerInfoOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,21 @@ final class GetCustomerInfoOperation: CacheableNetworkOperation {

static func createFactory(
configuration: UserSpecificConfiguration,
customerInfoResponseHandler: CustomerInfoResponseHandler = CustomerInfoResponseHandler(),
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
offlineCreator: OfflineCustomerInfoCreator?
) -> CacheableNetworkOperationFactory<GetCustomerInfoOperation> {
return Self.createFactory(
configuration: configuration,
customerInfoResponseHandler: .init(
offlineCreator: offlineCreator,
userID: configuration.appUserID
),
customerInfoCallbackCache: customerInfoCallbackCache)
}

static func createFactory(
configuration: UserSpecificConfiguration,
customerInfoResponseHandler: CustomerInfoResponseHandler,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>
) -> CacheableNetworkOperationFactory<GetCustomerInfoOperation> {
return .init({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ import Foundation

class CustomerInfoResponseHandler {

init() { }
private let offlineCreator: OfflineCustomerInfoCreator?
private let userID: String

/// - Parameter offlineCreator: can be `nil` if offline ``CustomerInfo`` shouldn't or can't be computed.
init(offlineCreator: OfflineCustomerInfoCreator?, userID: String) {
self.offlineCreator = offlineCreator
self.userID = userID
}

func handle(customerInfoResponse response: HTTPResponse<Response>.Result,
completion: CustomerAPI.CustomerInfoResponseHandler) {
completion: @escaping CustomerAPI.CustomerInfoResponseHandler) {
let result: Result<CustomerInfo, BackendError> = response
.map { response in
// If the response was successful we always want to return the `CustomerInfo`.
Expand All @@ -32,7 +39,28 @@ class CustomerInfoResponseHandler {
}
.mapError(BackendError.networkError)

completion(result)
self.handle(result: result, completion: completion)
}

private func handle(
result: Result<CustomerInfo, BackendError>,
completion: @escaping CustomerAPI.CustomerInfoResponseHandler
) {
guard let offlineCreator = self.offlineCreator,
result.error?.isServerDown == true,
NachoSoto marked this conversation as resolved.
Show resolved Hide resolved
#available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) else {
completion(result)
return
}

_ = Task<Void, Never> {
do {
completion(.success(try await offlineCreator.create(for: self.userID)))
} catch {
Logger.error(Strings.offlineEntitlements.computing_offline_customer_info_failed(error))
completion(result)
}
}
}

}
Expand Down
19 changes: 18 additions & 1 deletion Sources/Networking/Operations/PostReceiptDataOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,24 @@ final class PostReceiptDataOperation: CacheableNetworkOperation {
static func createFactory(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoResponseHandler: CustomerInfoResponseHandler = CustomerInfoResponseHandler(),
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>,
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?
) -> CacheableNetworkOperationFactory<PostReceiptDataOperation> {
return Self.createFactory(
configuration: configuration,
postData: postData,
customerInfoResponseHandler: .init(
offlineCreator: offlineCustomerInfoCreator,
userID: configuration.appUserID
),
customerInfoCallbackCache: customerInfoCallbackCache
)
}

static func createFactory(
configuration: UserSpecificConfiguration,
postData: PostData,
customerInfoResponseHandler: CustomerInfoResponseHandler,
customerInfoCallbackCache: CallbackCache<CustomerInfoCallback>
) -> CacheableNetworkOperationFactory<PostReceiptDataOperation> {
/// Cache key comprises of the following:
Expand Down
Loading