From 8d76d844f85b88ddecf5a69b29f4990a338c5c18 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Tue, 21 Jul 2026 17:07:25 +0200 Subject: [PATCH 1/9] Add remote config PEM topic provider --- RevenueCat.xcodeproj/project.pbxproj | 4 + .../Logging/Strings/RemoteConfigStrings.swift | 3 + ...oductEntitlementMappingTopicProvider.swift | 45 ++++++ Sources/Networking/RemoteConfigTopic.swift | 1 + .../OfflineEntitlementsManager.swift | 28 ++++ Sources/Purchasing/Purchases/Purchases.swift | 3 + .../RemoteConfigIntegrationTests.swift | 25 ++++ .../OfflineEntitlementsManagerTests.swift | 139 +++++++++++++++++- 8 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 Sources/Networking/ProductEntitlementMappingTopicProvider.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index e46351c5dc..1ca940cd7c 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -1175,6 +1175,7 @@ A1B2C3D42FE7000000000002 /* RemoteConfigTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42FE7000000000001 /* RemoteConfigTopic.swift */; }; A1B2C3D42FE8000000000002 /* RemoteConfigIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42FE8000000000001 /* RemoteConfigIntegrationTests.swift */; }; A1B2C3D42FE9000000000002 /* GenerationGuardedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42FE9000000000001 /* GenerationGuardedCache.swift */; }; + A1B2C3D42FEB000000000002 /* ProductEntitlementMappingTopicProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42FEB000000000001 /* ProductEntitlementMappingTopicProvider.swift */; }; A1B2C3D42FE9000000000004 /* GenerationGuardedCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42FE9000000000003 /* GenerationGuardedCacheTests.swift */; }; A1B2C3D4E5F60718293A4B5C /* MiscOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60718293A4B5C6D7E8F /* MiscOperators.swift */; }; A1E0F0022F297A0100000001 /* EventsManagerStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E0F0012F297A0100000001 /* EventsManagerStrings.swift */; }; @@ -2823,6 +2824,7 @@ A1B2C3D42FE7000000000001 /* RemoteConfigTopic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigTopic.swift; sourceTree = ""; }; A1B2C3D42FE8000000000001 /* RemoteConfigIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigIntegrationTests.swift; sourceTree = ""; }; A1B2C3D42FE9000000000001 /* GenerationGuardedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationGuardedCache.swift; sourceTree = ""; }; + A1B2C3D42FEB000000000001 /* ProductEntitlementMappingTopicProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductEntitlementMappingTopicProvider.swift; sourceTree = ""; }; A1B2C3D42FE9000000000003 /* GenerationGuardedCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationGuardedCacheTests.swift; sourceTree = ""; }; A1B2C3D42FEA000000000001 /* RecordingBlobDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingBlobDownloader.swift; sourceTree = ""; }; A1B2C3D42FEA000000000002 /* RemoteConfigBlobHealthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigBlobHealthTests.swift; sourceTree = ""; }; @@ -4658,6 +4660,7 @@ A1B2C3D42FE3000000000001 /* RemoteConfigFetchContext.swift */, A1B2C3D42FE9000000000001 /* GenerationGuardedCache.swift */, DB021C52E3CCC5EB17FFF795 /* UiConfigProvider.swift */, + A1B2C3D42FEB000000000001 /* ProductEntitlementMappingTopicProvider.swift */, 4C390BBAC430231B23B4B5F8 /* WorkflowsConfigProvider.swift */, A1B2C3D42FE7000000000001 /* RemoteConfigTopic.swift */, A1B2C3D42FE1000000000011 /* RemoteConfigSignatureContextProvider.swift */, @@ -7324,6 +7327,7 @@ A1B2C3D42FE3000000000002 /* RemoteConfigFetchContext.swift in Sources */, A1B2C3D42FE9000000000002 /* GenerationGuardedCache.swift in Sources */, B8EF700CBF25815EC2BF1253 /* UiConfigProvider.swift in Sources */, + A1B2C3D42FEB000000000002 /* ProductEntitlementMappingTopicProvider.swift in Sources */, 49B8016629C29C4691C0774A /* WorkflowsConfigProvider.swift in Sources */, A1B2C3D42FE2000000000006 /* RemoteConfigStrings.swift in Sources */, A1B2C3D42FE7000000000002 /* RemoteConfigTopic.swift in Sources */, diff --git a/Sources/Logging/Strings/RemoteConfigStrings.swift b/Sources/Logging/Strings/RemoteConfigStrings.swift index ff7af59736..9e86d47201 100644 --- a/Sources/Logging/Strings/RemoteConfigStrings.swift +++ b/Sources/Logging/Strings/RemoteConfigStrings.swift @@ -28,6 +28,7 @@ enum RemoteConfigStrings { case notModified case prefetchEnqueued(Int) case prefetchingBlobCount(Int) + case productEntitlementMappingDecodeFailed(Error) case receivedConfiguration(activeTopics: [String], changedTopics: [String]) case refreshing(domain: String, manifestPresent: Bool, isAppBackgrounded: Bool) case refreshFailed(BackendError) @@ -88,6 +89,8 @@ extension RemoteConfigStrings: LogMessage { return "Enqueued \(count) remote config blob prefetch downloads." case let .prefetchingBlobCount(count): return "Prefetching \(count) remote config blobs requested by the latest configuration." + case let .productEntitlementMappingDecodeFailed(error): + return "Failed to decode product_entitlement_mapping.default: \(error.localizedDescription)" case let .receivedConfiguration(activeTopics, changedTopics): return "Received remote config with \(activeTopics.count) active topics " + "(\(activeTopics.sorted().joined(separator: ", "))) and \(changedTopics.count) changed topics " + diff --git a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift new file mode 100644 index 0000000000..0c075fae05 --- /dev/null +++ b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift @@ -0,0 +1,45 @@ +// +// ProductEntitlementMappingTopicProvider.swift +// RevenueCat +// +// Created by Rick van der Linden. +// Copyright © 2026 RevenueCat, Inc. All rights reserved. + +import Foundation + +protocol EntitlementMappingTopicProviderType: AnyObject { + + /// Returns the mapping when its remote-config blob is available and decodes successfully. + func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? + +} + +/// Decodes the `product_entitlement_mapping.default` remote-config blob. +final class ProductEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType { + + private static let itemKey = "default" + + private weak var manager: RemoteConfigManagerType? + + init(manager: RemoteConfigManagerType) { + self.manager = manager + } + + func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? { + guard let manager = self.manager else { return nil } + + do { + return try await manager.blobData( + for: .productEntitlementMapping, + itemKey: Self.itemKey, + as: ProductEntitlementMappingResponse.self + ) + } catch { + Logger.error(Strings.remoteConfig.productEntitlementMappingDecodeFailed(error)) + return nil + } + } + +} + +extension ProductEntitlementMappingTopicProvider: @unchecked Sendable {} diff --git a/Sources/Networking/RemoteConfigTopic.swift b/Sources/Networking/RemoteConfigTopic.swift index 1bacf1afe6..edfd2bf526 100644 --- a/Sources/Networking/RemoteConfigTopic.swift +++ b/Sources/Networking/RemoteConfigTopic.swift @@ -16,6 +16,7 @@ enum RemoteConfigTopic: String { case workflows case uiConfig = "ui_config" case sources + case productEntitlementMapping = "product_entitlement_mapping" var wireName: String { return self.rawValue diff --git a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift index 3a8ef89d72..7e0e5e3f9b 100644 --- a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift +++ b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift @@ -20,6 +20,8 @@ class OfflineEntitlementsManager { private let api: OfflineEntitlementsAPI private let systemInfo: SystemInfo + private var productEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType? + init(deviceCache: DeviceCache, operationDispatcher: OperationDispatcher, api: OfflineEntitlementsAPI, @@ -30,6 +32,11 @@ class OfflineEntitlementsManager { self.systemInfo = systemInfo } + // Late-bound to break the OfflineEntitlementsManager → IdentityManager → RemoteConfigManager dependency cycle. + func setProductEntitlementMappingTopicProvider(_ provider: EntitlementMappingTopicProviderType) { + self.productEntitlementMappingTopicProvider = provider + } + func updateProductsEntitlementsCacheIfStale( isAppBackgrounded: Bool, completion: (@MainActor @Sendable (Result<(), Error>) -> Void)? @@ -49,6 +56,27 @@ class OfflineEntitlementsManager { Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_stale_updating) + Task { [weak self] in + guard let self else { return } + + if let response = await self.productEntitlementMappingTopicProvider?.getProductEntitlementMapping() { + self.handleProductEntitlementMappingBackendResult(with: response) + self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(())) + return + } + + self.fetchLegacyProductEntitlementMapping( + isAppBackgrounded: isAppBackgrounded, + completion: completion + ) + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func fetchLegacyProductEntitlementMapping( + isAppBackgrounded: Bool, + completion: (@MainActor @Sendable (Result<(), Error>) -> Void)? + ) { self.api.getProductEntitlementMapping(isAppBackgrounded: isAppBackgrounded) { result in switch result { case let .success(response): diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index ecb593db65..c7de76b862 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -869,6 +869,9 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void super.init() self.identityManager.remoteConfigManager = self.remoteConfigManager + self.offlineEntitlementsManager.setProductEntitlementMappingTopicProvider( + ProductEntitlementMappingTopicProvider(manager: self.remoteConfigManager) + ) self.remoteConfigManager.onRemoteConfigDisabled = { [weak self] in guard let self else { return } self.offeringsManager.invalidateAndReFetchCachedOfferingsIfAppropiate(appUserID: self.appUserID) diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift index ba35e94695..220aa223c4 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift @@ -446,6 +446,31 @@ final class RemoteConfigIntegrationTests: TestCase { expect(data) == blob } + func testProductEntitlementMappingDecodesFromBackendResponse() async throws { + let mapping = ProductEntitlementMappingResponse(products: [ + "monthly": .init(identifier: "monthly", entitlements: ["pro"]) + ]) + let blob = try JSONEncoder.default.encode(mapping) + let ref = RCContainerTestData.blobRef(for: blob) + let topics = RemoteConfiguration.Topics(entries: [ + RemoteConfigTopic.productEntitlementMapping.wireName: ["default": .init(blobRef: ref)] + ]) + let container = try Self.containerData( + topics: topics, + contentElements: [(blob, .none)] + ) + + await self.refresh(with: container) + + let provider = ProductEntitlementMappingTopicProvider(manager: self.manager) + let result = await provider.getProductEntitlementMapping() + let requestedURLs = await self.downloader.requestedURLs() + + expect(result) == mapping + expect(self.remoteConfigRequestCount) == 1 + expect(requestedURLs).to(beEmpty()) + } + func testEnforcedVerificationFailureDoesNotPersistResponse() async throws { self.mockRemoteConfigError(.signatureVerificationFailed( path: HTTPRequest.Path.remoteConfig(domain: RemoteConfiguration.defaultDomain), diff --git a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift index 71914a1bc7..530e11351d 100644 --- a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift +++ b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift @@ -24,6 +24,7 @@ class BaseOfflineEntitlementsManagerTests: TestCase { var mockDeviceCache: MockDeviceCache! let mockOperationDispatcher = MockOperationDispatcher() var mockOfflineEntitlements: MockOfflineEntitlementsAPI! + var mockProductEntitlementMappingTopicProvider: MockEntitlementMappingTopicProvider! var manager: OfflineEntitlementsManager! @@ -35,14 +36,17 @@ class BaseOfflineEntitlementsManagerTests: TestCase { self.mockBackend.offlineEntitlements as? MockOfflineEntitlementsAPI ) self.mockDeviceCache = MockDeviceCache(systemInfo: self.mockSystemInfo) + self.mockProductEntitlementMappingTopicProvider = MockEntitlementMappingTopicProvider() self.manager = self.createManager() } fileprivate func createManager() -> OfflineEntitlementsManager { - return OfflineEntitlementsManager(deviceCache: self.mockDeviceCache, - operationDispatcher: self.mockOperationDispatcher, - api: self.mockOfflineEntitlements, - systemInfo: self.mockSystemInfo) + let manager = OfflineEntitlementsManager(deviceCache: self.mockDeviceCache, + operationDispatcher: self.mockOperationDispatcher, + api: self.mockOfflineEntitlements, + systemInfo: self.mockSystemInfo) + manager.setProductEntitlementMappingTopicProvider(self.mockProductEntitlementMappingTopicProvider) + return manager } } @@ -95,6 +99,66 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe expect(result).to(beSuccess()) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 0 + } + + func testUpdateProductEntitlementMappingCacheUsesRemoteConfig() throws { + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "a": .init(identifier: "a", entitlements: ["pro_1", "pro_2"]) + ]) + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + self.mockProductEntitlementMappingTopicProvider.stubbedProductEntitlementMapping = mapping + + let result = waitUntilValue { completion in + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { + completion($0) + } + } + + expect(result).to(beSuccess()) + expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 1 + } + + func testUnavailableRemoteConfigMappingFallsBackToLegacyEndpoint() { + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "legacy": .init(identifier: "legacy", entitlements: ["pro"]) + ]) + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(mapping) + + let result = waitUntilValue { completion in + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { + completion($0) + } + } + + expect(result).to(beSuccess()) + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true + expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() + } + + func testDisabledRemoteConfigFallsBackToLegacyEndpoint() { + let remoteConfigManager = NoOpRemoteConfigManager() + self.manager.setProductEntitlementMappingTopicProvider( + ProductEntitlementMappingTopicProvider(manager: remoteConfigManager) + ) + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "legacy": .init(identifier: "legacy", entitlements: ["pro"]) + ]) + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(mapping) + + let result = waitUntilValue { completion in + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { + completion($0) + } + } + + expect(result).to(beSuccess()) + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true + expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() } func testUpdateProductEntitlementMappingCacheUpdatesIfStaleSuccess() { @@ -157,6 +221,73 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe } +final class EntitlementMappingTopicProviderTests: TestCase { + + private var remoteConfigManager: MockRemoteConfigManager! + private var provider: ProductEntitlementMappingTopicProvider! + + override func setUp() { + super.setUp() + + self.remoteConfigManager = MockRemoteConfigManager() + self.provider = ProductEntitlementMappingTopicProvider(manager: self.remoteConfigManager) + } + + func testReturnsDecodedDefaultBlob() async throws { + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "monthly": .init(identifier: "monthly", entitlements: ["pro"]) + ]) + self.remoteConfigManager.stubbedBlobData[.productEntitlementMapping] = [ + "default": try JSONEncoder.default.encode(mapping) + ] + + let result = await self.provider.getProductEntitlementMapping() + + expect(result) == mapping + expect(self.remoteConfigManager.invokedBlobDataParameters.count) == 1 + expect(self.remoteConfigManager.invokedBlobDataParameters.first?.topic) + == .productEntitlementMapping + expect(self.remoteConfigManager.invokedBlobDataParameters.first?.itemKey) == "default" + } + + func testReturnsNilWhenBlobIsUnavailable() async { + let result = await self.provider.getProductEntitlementMapping() + + expect(result).to(beNil()) + } + + func testReturnsNilWhenBlobCannotBeDecoded() async { + self.remoteConfigManager.stubbedBlobData[.productEntitlementMapping] = [ + "default": "{ invalid json".asData + ] + + let result = await self.provider.getProductEntitlementMapping() + + expect(result).to(beNil()) + } + + func testReturnsNilWhenRemoteConfigManagerIsReleased() async { + self.remoteConfigManager = nil + + let result = await self.provider.getProductEntitlementMapping() + + expect(result).to(beNil()) + } + +} + +final class MockEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType { + + var stubbedProductEntitlementMapping: ProductEntitlementMappingResponse? + private(set) var invokedGetProductEntitlementMappingCount = 0 + + func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? { + self.invokedGetProductEntitlementMappingCount += 1 + return self.stubbedProductEntitlementMapping + } + +} + // swiftlint:disable:next type_name class OfflineEntitlementsManagerUnavailableTests: BaseOfflineEntitlementsManagerTests { From c91341569609bd71d6eb6a1c3dba5b7afe2290b7 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:12:02 +0200 Subject: [PATCH 2/9] Guard Remote Config blob cache handoff --- Sources/Networking/RemoteConfigManager.swift | 109 +++++++++++++++++- .../RemoteConfigManagerTests.swift | 18 +++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/RemoteConfigManager.swift b/Sources/Networking/RemoteConfigManager.swift index f22cd839ec..c4723d5af7 100644 --- a/Sources/Networking/RemoteConfigManager.swift +++ b/Sources/Networking/RemoteConfigManager.swift @@ -9,6 +9,17 @@ import Foundation // swiftlint:disable file_length +struct RemoteConfigBlobData: @unchecked Sendable { + + let value: Value + fileprivate let topic: RemoteConfigTopic + fileprivate let itemKey: String + fileprivate let blobRef: String + fileprivate let epoch: Int + fileprivate let generation: Int + +} + protocol RemoteConfigManagerType: AnyObject { /// Whether remote config should be ignored for the current manager lifetime. @@ -34,6 +45,12 @@ protocol RemoteConfigManagerType: AnyObject { /// wait for an in-flight refresh or trigger one foreground refresh before resolving the blob on demand. func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? + /// Returns blob bytes together with the exact committed config state that produced them. + func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? + + /// Runs `operation` atomically only if `snapshot` still belongs to the current committed config. + func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool + /// Decodes a blob payload as a concrete `Decodable` type. /// /// Returns `nil` when the item or blob is unavailable. Throws when bytes are available but cannot be decoded. @@ -77,6 +94,24 @@ protocol RemoteConfigManagerType: AnyObject { extension RemoteConfigManagerType { + func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { + guard let data = await self.blobData(for: topic, itemKey: itemKey) else { return nil } + return .init( + value: data, + topic: topic, + itemKey: itemKey, + blobRef: "", + epoch: 0, + generation: self.configGeneration + ) + } + + func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { + guard self.configGeneration == snapshot.generation else { return false } + operation(snapshot.value) + return true + } + func withCurrentConfigGeneration(_ operation: (Int) -> T?) -> T? { let generation = self.configGeneration guard let value = operation(generation), @@ -213,9 +248,17 @@ final class NoOpRemoteConfigManager: RemoteConfigManagerType { } func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? { + return await self.blobDataSnapshot(for: topic, itemKey: itemKey)?.value + } + + func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { return nil } + func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { + return false + } + func blobData( for topic: RemoteConfigTopic, itemKey: String, @@ -374,15 +417,45 @@ final class RemoteConfigManager: RemoteConfigManagerType { } func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? { + return await self.blobDataSnapshot(for: topic, itemKey: itemKey)?.value + } + + func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { guard let itemSnapshot = await self.readCommittedStateSnapshot(refreshIfMissing: true, { await self.committedTopic(topic)?[itemKey] }), - let item = itemSnapshot.value else { + let item = itemSnapshot.value, + let blobRef = item.blobRef, + let generation = self.currentGeneration( + for: topic, + itemKey: itemKey, + blobRef: blobRef, + epoch: itemSnapshot.epoch + ) else { return nil } - return await self.readCommittedState(epoch: itemSnapshot.epoch) { + guard let data = await self.readCommittedState(epoch: itemSnapshot.epoch, { await self.blobData(for: item) + }) else { return nil } + + let snapshot = RemoteConfigBlobData( + value: data, + topic: topic, + itemKey: itemKey, + blobRef: blobRef, + epoch: itemSnapshot.epoch, + generation: generation + ) + return self.isCurrent(snapshot) ? snapshot : nil + } + + func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { + return self.lock.perform { + guard self.isCurrentWithoutLock(snapshot) else { return false } + + operation(snapshot.value) + return true } } @@ -883,6 +956,38 @@ private extension RemoteConfigManager { } } + func currentGeneration( + for topic: RemoteConfigTopic, + itemKey: String, + blobRef: String, + epoch: Int + ) -> Int? { + return self.lock.perform { + guard !self.isDisabledInternal, + !self.isClosed, + self.epoch == epoch, + self.diskCache.topic(topic)?[itemKey]?.blobRef == blobRef else { + return nil + } + + return self.generation + } + } + + func isCurrent(_ snapshot: RemoteConfigBlobData) -> Bool { + return self.lock.perform { + self.isCurrentWithoutLock(snapshot) + } + } + + func isCurrentWithoutLock(_ snapshot: RemoteConfigBlobData) -> Bool { + return !self.isDisabledInternal && + !self.isClosed && + self.epoch == snapshot.epoch && + self.generation == snapshot.generation && + self.diskCache.topic(snapshot.topic)?[snapshot.itemKey]?.blobRef == snapshot.blobRef + } + /// Reads committed topic metadata off the caller's executor. func committedTopic(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic? { return await self.performRead { diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift index 6b7b3b5df0..4a796f5005 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift @@ -628,6 +628,24 @@ final class RemoteConfigManagerTests: TestCase { expect(self.blobStore.invokedReadRefs) == [ref] } + func testBlobDataSnapshotIsRejectedAfterIdentityInvalidation() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") + let snapshot = try XCTUnwrap(maybeSnapshot) + + self.manager.clearCache(forAppUserID: "new-user") + + var invoked = false + expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false + expect(invoked) == false + } + func testEnsureBlobsDownloadedDelegatesToBlobFetcher() async { let refs = ["ref-1", "ref-2"] From 884eee3b9338cfbbffa1dd83173a7266abe11ca4 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:12:14 +0200 Subject: [PATCH 3/9] Keep remote entitlement mappings generation-bound --- ...oductEntitlementMappingTopicProvider.swift | 46 ++++++++++++++++--- .../RemoteConfigIntegrationTests.swift | 2 +- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift index 0c075fae05..5cce2e6309 100644 --- a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift +++ b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift @@ -9,8 +9,29 @@ import Foundation protocol EntitlementMappingTopicProviderType: AnyObject { + var isAvailable: Bool { get } + /// Returns the mapping when its remote-config blob is available and decodes successfully. - func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? + func getProductEntitlementMapping() async -> ProductEntitlementMappingResult? + +} + +struct ProductEntitlementMappingResult: @unchecked Sendable { + + let response: ProductEntitlementMappingResponse + private let useIfCurrentOperation: ((ProductEntitlementMappingResponse) -> Void) -> Bool + + init( + response: ProductEntitlementMappingResponse, + useIfCurrent: @escaping ((ProductEntitlementMappingResponse) -> Void) -> Bool + ) { + self.response = response + self.useIfCurrentOperation = useIfCurrent + } + + func useIfCurrent(_ operation: (ProductEntitlementMappingResponse) -> Void) -> Bool { + return self.useIfCurrentOperation(operation) + } } @@ -25,15 +46,26 @@ final class ProductEntitlementMappingTopicProvider: EntitlementMappingTopicProvi self.manager = manager } - func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? { - guard let manager = self.manager else { return nil } + var isAvailable: Bool { + guard let manager = self.manager else { return false } + return !manager.isDisabled + } + + func getProductEntitlementMapping() async -> ProductEntitlementMappingResult? { + guard let manager = self.manager, + let blobData = await manager.blobDataSnapshot( + for: .productEntitlementMapping, + itemKey: Self.itemKey + ) else { return nil } do { - return try await manager.blobData( - for: .productEntitlementMapping, - itemKey: Self.itemKey, - as: ProductEntitlementMappingResponse.self + let response = try JSONDecoder.default.decode( + ProductEntitlementMappingResponse.self, + from: blobData.value ) + return ProductEntitlementMappingResult(response: response) { operation in + manager.useIfCurrent(blobData) { _ in operation(response) } + } } catch { Logger.error(Strings.remoteConfig.productEntitlementMappingDecodeFailed(error)) return nil diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift index 220aa223c4..be7028258a 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift @@ -466,7 +466,7 @@ final class RemoteConfigIntegrationTests: TestCase { let result = await provider.getProductEntitlementMapping() let requestedURLs = await self.downloader.requestedURLs() - expect(result) == mapping + expect(result?.response) == mapping expect(self.remoteConfigRequestCount) == 1 expect(requestedURLs).to(beEmpty()) } From 1c0bad9d86b3ae5baccbe5b18d27bedfcc0060cd Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:12:29 +0200 Subject: [PATCH 4/9] Preserve offline entitlement fallback lifecycle --- .../OfflineEntitlementsManager.swift | 83 +++++++++++++++++-- Sources/Purchasing/Purchases/Purchases.swift | 1 + 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift index 7e0e5e3f9b..dbc4ebfe23 100644 --- a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift +++ b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift @@ -21,6 +21,10 @@ class OfflineEntitlementsManager { private let systemInfo: SystemInfo private var productEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType? + private let productEntitlementMappingTasksLock = Lock() + private var productEntitlementMappingTasks: [UUID: Task] = [:] + private var completedProductEntitlementMappingTaskIDs: Set = [] + private var isClosed = false init(deviceCache: DeviceCache, operationDispatcher: OperationDispatcher, @@ -54,21 +58,84 @@ class OfflineEntitlementsManager { return } + guard self.productEntitlementMappingTasksLock.perform({ !self.isClosed }) else { return } + Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_stale_updating) - Task { [weak self] in + guard let provider = self.productEntitlementMappingTopicProvider, + provider.isAvailable else { + self.productEntitlementMappingTasksLock.perform { + guard !self.isClosed else { return } + self.fetchLegacyProductEntitlementMapping( + isAppBackgrounded: isAppBackgrounded, + completion: completion + ) + } + return + } + + let taskID = UUID() + let task = Task { [weak self] in + defer { self?.productEntitlementMappingTaskDidFinish(taskID) } guard let self else { return } - if let response = await self.productEntitlementMappingTopicProvider?.getProductEntitlementMapping() { - self.handleProductEntitlementMappingBackendResult(with: response) - self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(())) + if let result = await provider.getProductEntitlementMapping(), + self.productEntitlementMappingTasksLock.perform({ + guard !self.isClosed, !Task.isCancelled else { return false } + guard result.useIfCurrent({ + self.handleProductEntitlementMappingBackendResult(with: $0) + }) else { return false } + self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(())) + return true + }) { return } - self.fetchLegacyProductEntitlementMapping( - isAppBackgrounded: isAppBackgrounded, - completion: completion - ) + self.productEntitlementMappingTasksLock.perform { + guard !self.isClosed, !Task.isCancelled else { return } + self.fetchLegacyProductEntitlementMapping( + isAppBackgrounded: isAppBackgrounded, + completion: completion + ) + } + } + self.registerProductEntitlementMappingTask(task, id: taskID) + } + + func close() { + let tasks = self.productEntitlementMappingTasksLock.perform { + self.isClosed = true + let tasks = Array(self.productEntitlementMappingTasks.values) + self.productEntitlementMappingTasks.removeAll() + self.completedProductEntitlementMappingTaskIDs.removeAll() + return tasks + } + tasks.forEach { $0.cancel() } + } + + private func registerProductEntitlementMappingTask(_ task: Task, id: UUID) { + let shouldCancel = self.productEntitlementMappingTasksLock.perform { + if self.isClosed { + self.completedProductEntitlementMappingTaskIDs.remove(id) + return true + } + if self.completedProductEntitlementMappingTaskIDs.remove(id) != nil { + return false + } + + self.productEntitlementMappingTasks[id] = task + return false + } + if shouldCancel { + task.cancel() + } + } + + private func productEntitlementMappingTaskDidFinish(_ id: UUID) { + self.productEntitlementMappingTasksLock.perform { + if self.productEntitlementMappingTasks.removeValue(forKey: id) == nil, !self.isClosed { + self.completedProductEntitlementMappingTaskIDs.insert(id) + } } } diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index c7de76b862..443a503279 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -934,6 +934,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void self.notificationCenter.removeObserver(self) self.paymentQueueWrapper.sk1Wrapper?.delegate = nil self.paymentQueueWrapper.sk2Wrapper?.delegate = nil + self.offlineEntitlementsManager.close() self.remoteConfigManager.close() self.customerInfoObservationDisposable?() self.privateDelegate = nil From 18cc6b15f6aaa30d3fd17ee0d4fd515008ea794e Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Wed, 22 Jul 2026 12:12:40 +0200 Subject: [PATCH 5/9] Test offline entitlement fallback lifecycle --- .../OfflineEntitlementsManagerTests.swift | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift index 530e11351d..bff585f4d8 100644 --- a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift +++ b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift @@ -161,6 +161,58 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() } + func testDisabledRemoteConfigEnqueuesLegacyRequestSynchronously() { + self.manager.setProductEntitlementMappingTopicProvider( + ProductEntitlementMappingTopicProvider(manager: NoOpRemoteConfigManager()) + ) + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false, completion: nil) + + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true + } + + func testInvalidatedRemoteConfigMappingFallsBackToLegacyEndpoint() { + let remoteMapping: ProductEntitlementMappingResponse = .init(products: [ + "remote": .init(identifier: "remote", entitlements: ["pro"]) + ]) + let legacyMapping: ProductEntitlementMappingResponse = .init(products: [ + "legacy": .init(identifier: "legacy", entitlements: ["pro"]) + ]) + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + self.mockProductEntitlementMappingTopicProvider.stubbedProductEntitlementMapping = remoteMapping + self.mockProductEntitlementMappingTopicProvider.stubbedUseIfCurrent = false + self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(legacyMapping) + + let result = waitUntilValue { completion in + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { + completion($0) + } + } + + expect(result).to(beSuccess()) + expect(self.mockDeviceCache.cachedProductEntitlementMapping) == legacyMapping.toMapping() + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true + } + + func testCloseCancelsRemoteConfigReadWithoutStartingLegacyFallback() async { + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + self.mockProductEntitlementMappingTopicProvider.getProductEntitlementMappingHandler = { + do { + try await Task.sleep(nanoseconds: 60_000_000_000) + } catch {} + return nil + } + + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false, completion: nil) + await Task.yield() + self.manager.close() + await Task.yield() + + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 1 + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + } + func testUpdateProductEntitlementMappingCacheUpdatesIfStaleSuccess() { let mapping: ProductEntitlementMappingResponse = .init(products: [ "a": .init(identifier: "a", entitlements: ["pro_1", "pro_2"]) @@ -243,7 +295,7 @@ final class EntitlementMappingTopicProviderTests: TestCase { let result = await self.provider.getProductEntitlementMapping() - expect(result) == mapping + expect(result?.response) == mapping expect(self.remoteConfigManager.invokedBlobDataParameters.count) == 1 expect(self.remoteConfigManager.invokedBlobDataParameters.first?.topic) == .productEntitlementMapping @@ -274,16 +326,41 @@ final class EntitlementMappingTopicProviderTests: TestCase { expect(result).to(beNil()) } + func testResultIsRejectedWhenRemoteConfigChangesBeforeUse() async throws { + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "monthly": .init(identifier: "monthly", entitlements: ["pro"]) + ]) + self.remoteConfigManager.stubbedBlobData[.productEntitlementMapping] = [ + "default": try JSONEncoder.default.encode(mapping) + ] + let result = await self.provider.getProductEntitlementMapping() + + self.remoteConfigManager.clearCache() + + expect(result?.useIfCurrent { _ in }).to(beFalse()) + } + } final class MockEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType { + var isAvailable = true var stubbedProductEntitlementMapping: ProductEntitlementMappingResponse? + var stubbedUseIfCurrent = true + var getProductEntitlementMappingHandler: (() async -> ProductEntitlementMappingResult?)? private(set) var invokedGetProductEntitlementMappingCount = 0 - func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? { + func getProductEntitlementMapping() async -> ProductEntitlementMappingResult? { self.invokedGetProductEntitlementMappingCount += 1 - return self.stubbedProductEntitlementMapping + if let handler = self.getProductEntitlementMappingHandler { + return await handler() + } + guard let mapping = self.stubbedProductEntitlementMapping else { return nil } + return ProductEntitlementMappingResult(response: mapping) { operation in + guard self.stubbedUseIfCurrent else { return false } + operation(mapping) + return true + } } } From 8b9ace74f51983813f0a651eda20a9c45c69f321 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 07:43:24 +0200 Subject: [PATCH 6/9] Simplify generation-guarded blob reads --- Sources/Networking/RemoteConfigManager.swift | 109 ++++++------------ .../RemoteConfigManagerTests.swift | 86 ++++++++++++++ .../OfflineEntitlementsManagerTests.swift | 18 +++ .../Purchases/BasePurchasesTests.swift | 78 +++++++++++-- 4 files changed, 212 insertions(+), 79 deletions(-) diff --git a/Sources/Networking/RemoteConfigManager.swift b/Sources/Networking/RemoteConfigManager.swift index c4723d5af7..22688d682d 100644 --- a/Sources/Networking/RemoteConfigManager.swift +++ b/Sources/Networking/RemoteConfigManager.swift @@ -12,12 +12,17 @@ import Foundation struct RemoteConfigBlobData: @unchecked Sendable { let value: Value - fileprivate let topic: RemoteConfigTopic - fileprivate let itemKey: String - fileprivate let blobRef: String - fileprivate let epoch: Int fileprivate let generation: Int + init(value: Value, generation: Int) { + self.value = value + self.generation = generation + } + + func belongs(toGeneration generation: Int) -> Bool { + return self.generation == generation + } + } protocol RemoteConfigManagerType: AnyObject { @@ -45,7 +50,7 @@ protocol RemoteConfigManagerType: AnyObject { /// wait for an in-flight refresh or trigger one foreground refresh before resolving the blob on demand. func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? - /// Returns blob bytes together with the exact committed config state that produced them. + /// Returns blob bytes together with the committed config generation that produced them. func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? /// Runs `operation` atomically only if `snapshot` still belongs to the current committed config. @@ -94,24 +99,6 @@ protocol RemoteConfigManagerType: AnyObject { extension RemoteConfigManagerType { - func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { - guard let data = await self.blobData(for: topic, itemKey: itemKey) else { return nil } - return .init( - value: data, - topic: topic, - itemKey: itemKey, - blobRef: "", - epoch: 0, - generation: self.configGeneration - ) - } - - func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { - guard self.configGeneration == snapshot.generation else { return false } - operation(snapshot.value) - return true - } - func withCurrentConfigGeneration(_ operation: (Int) -> T?) -> T? { let generation = self.configGeneration guard let value = operation(generation), @@ -425,34 +412,44 @@ final class RemoteConfigManager: RemoteConfigManagerType { await self.committedTopic(topic)?[itemKey] }), let item = itemSnapshot.value, - let blobRef = item.blobRef, - let generation = self.currentGeneration( - for: topic, - itemKey: itemKey, - blobRef: blobRef, - epoch: itemSnapshot.epoch - ) else { + let blobRef = item.blobRef else { return nil } + let generation = self.lock.perform { + guard !self.isDisabledInternal, + !self.isClosed, + self.epoch == itemSnapshot.epoch, + self.diskCache.topic(topic)?[itemKey]?.blobRef == blobRef else { + return nil as Int? + } + + return self.generation + } + guard let generation else { return nil } + guard let data = await self.readCommittedState(epoch: itemSnapshot.epoch, { await self.blobData(for: item) }) else { return nil } - let snapshot = RemoteConfigBlobData( - value: data, - topic: topic, - itemKey: itemKey, - blobRef: blobRef, - epoch: itemSnapshot.epoch, - generation: generation - ) - return self.isCurrent(snapshot) ? snapshot : nil + return self.lock.perform { + guard !self.isDisabledInternal, + !self.isClosed, + self.generation == generation else { + return nil + } + + return RemoteConfigBlobData(value: data, generation: generation) + } } func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { return self.lock.perform { - guard self.isCurrentWithoutLock(snapshot) else { return false } + guard !self.isDisabledInternal, + !self.isClosed, + self.generation == snapshot.generation else { + return false + } operation(snapshot.value) return true @@ -956,38 +953,6 @@ private extension RemoteConfigManager { } } - func currentGeneration( - for topic: RemoteConfigTopic, - itemKey: String, - blobRef: String, - epoch: Int - ) -> Int? { - return self.lock.perform { - guard !self.isDisabledInternal, - !self.isClosed, - self.epoch == epoch, - self.diskCache.topic(topic)?[itemKey]?.blobRef == blobRef else { - return nil - } - - return self.generation - } - } - - func isCurrent(_ snapshot: RemoteConfigBlobData) -> Bool { - return self.lock.perform { - self.isCurrentWithoutLock(snapshot) - } - } - - func isCurrentWithoutLock(_ snapshot: RemoteConfigBlobData) -> Bool { - return !self.isDisabledInternal && - !self.isClosed && - self.epoch == snapshot.epoch && - self.generation == snapshot.generation && - self.diskCache.topic(snapshot.topic)?[snapshot.itemKey]?.blobRef == snapshot.blobRef - } - /// Reads committed topic metadata off the caller's executor. func committedTopic(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic? { return await self.performRead { diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift index 4a796f5005..be19c485cc 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift @@ -646,6 +646,92 @@ final class RemoteConfigManagerTests: TestCase { expect(invoked) == false } + func testUseIfCurrentExecutesExactlyOnceForCurrentBlobSnapshot() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") + let snapshot = try XCTUnwrap(maybeSnapshot) + var invocationCount = 0 + + let used = self.manager.useIfCurrent(snapshot) { value in + invocationCount += 1 + expect(value) == data + } + + expect(used) == true + expect(invocationCount) == 1 + } + + func testUseIfCurrentRejectsBlobSnapshotAfterNewerConfigCommit() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") + let snapshot = try XCTUnwrap(maybeSnapshot) + let replacementData = #"{"id":"replacement"}"#.asData + let replacementRef = RCContainerTestData.blobRef(for: replacementData) + let response = """ + { + "domain": "app", + "manifest": "v2", + "active_topics": ["workflows"], + "topics": { "workflows": { "default": { "blob_ref": "\(replacementRef)" } } } + } + """ + self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) + self.remoteConfigAPI.complete( + with: .success(.test(container: try Self.container(config: response))) + ) + var invoked = false + + expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false + expect(invoked) == false + } + + func testUseIfCurrentRejectsBlobSnapshotAfterRemoteConfigIsDisabled() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") + let snapshot = try XCTUnwrap(maybeSnapshot) + self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) + self.remoteConfigAPI.complete(with: .failure(Self.backendError(statusCode: .forbidden))) + var invoked = false + + expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false + expect(invoked) == false + } + + func testUseIfCurrentRejectsBlobSnapshotAfterManagerCloses() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") + let snapshot = try XCTUnwrap(maybeSnapshot) + self.manager.close() + var invoked = false + + expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false + expect(invoked) == false + } + func testEnsureBlobsDownloadedDelegatesToBlobFetcher() async { let refs = ["ref-1", "ref-2"] diff --git a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift index bff585f4d8..6515dd8e91 100644 --- a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift +++ b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift @@ -340,6 +340,24 @@ final class EntitlementMappingTopicProviderTests: TestCase { expect(result?.useIfCurrent { _ in }).to(beFalse()) } + func testReturnsNilWhenRemoteConfigChangesDuringBlobRead() async throws { + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "monthly": .init(identifier: "monthly", entitlements: ["pro"]) + ]) + self.remoteConfigManager.stubbedBlobData[.productEntitlementMapping] = [ + "default": try JSONEncoder.default.encode(mapping) + ] + self.remoteConfigManager.shouldStoreBlobDataCompletion = true + + async let result = self.provider.getProductEntitlementMapping() + await expect(self.remoteConfigManager.invokedBlobDataParameters).toEventuallyNot(beEmpty()) + self.remoteConfigManager.clearCache() + self.remoteConfigManager.completeStoredBlobReads() + + let resolvedResult = await result + expect(resolvedResult).to(beNil()) + } + } final class MockEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType { diff --git a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift index 9cbdf2c362..7c6c4e5efd 100644 --- a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift @@ -679,19 +679,41 @@ final class MockRemoteConfigManager: RemoteConfigManagerType { let isAppBackgrounded: Bool } - var isDisabled = false + private let stateLock = Lock() + private var isDisabledStorage = false + private var isClosed = false + private var configGenerationStorage = 0 + + var isDisabled: Bool { + get { + return self.stateLock.perform { + self.isDisabledStorage + } + } + set { + self.stateLock.perform { + guard self.isDisabledStorage != newValue else { return } + self.isDisabledStorage = newValue + self.configGenerationStorage += 1 + } + } + } var onRemoteConfigDisabled: (() -> Void)? var onConfigGenerationRead: (() -> Void)? var configGeneration: Int { get { - defer { self.onConfigGenerationRead?() } - return self.configGenerationStorage + let generation = self.stateLock.perform { + self.configGenerationStorage + } + self.onConfigGenerationRead?() + return generation } set { - self.configGenerationStorage = newValue + self.stateLock.perform { + self.configGenerationStorage = newValue + } } } - private var configGenerationStorage = 0 private(set) var invokedRefreshRemoteConfigCount = 0 private(set) var invokedRefreshRemoteConfigIfStaleCount = 0 @@ -783,6 +805,40 @@ final class MockRemoteConfigManager: RemoteConfigManagerType { } } + func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { + let generation = self.stateLock.perform { + guard !self.isDisabledStorage, !self.isClosed else { return nil as Int? } + return self.configGenerationStorage + } + guard let generation, + let data = await self.blobData(for: topic, itemKey: itemKey) else { + return nil + } + + return self.stateLock.perform { + guard !self.isDisabledStorage, + !self.isClosed, + self.configGenerationStorage == generation else { + return nil + } + + return RemoteConfigBlobData(value: data, generation: generation) + } + } + + func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { + return self.stateLock.perform { + guard !self.isDisabledStorage, + !self.isClosed, + snapshot.belongs(toGeneration: self.configGenerationStorage) else { + return false + } + + operation(snapshot.value) + return true + } + } + /// Resumes every held blob read with its key's stubbed data and stops holding subsequent /// reads, so sequential read chains (like `mergeItemsBlobData`'s loop) run to completion. func completeStoredBlobReads() { @@ -831,17 +887,25 @@ final class MockRemoteConfigManager: RemoteConfigManagerType { } func clearCache() { - self.configGeneration += 1 + self.stateLock.perform { + self.configGenerationStorage += 1 + } self.invokedClearCacheCount += 1 } func clearCache(forAppUserID appUserID: String) { - self.configGeneration += 1 + self.stateLock.perform { + self.configGenerationStorage += 1 + } self.invokedClearCacheCount += 1 self.invokedClearCacheAppUserIDs.append(appUserID) } func close() { + self.stateLock.perform { + self.isClosed = true + self.configGenerationStorage += 1 + } self.invokedCloseCount += 1 } From 456e19e1a5daabef4c88972ab0e61cf0fecdfc05 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 08:59:41 +0200 Subject: [PATCH 7/9] Simplify offline entitlement mapping updates --- Sources/Networking/RemoteConfigManager.swift | 17 +- .../OfflineEntitlementsManager.swift | 164 +++++++----------- Sources/Purchasing/Purchases/Purchases.swift | 15 +- .../MockOfflineEntitlementsManager.swift | 6 +- .../RemoteConfigManagerTests.swift | 64 +++++++ .../OfflineEntitlementsManagerTests.swift | 133 ++++++-------- .../Purchases/BasePurchasesTests.swift | 2 +- 7 files changed, 198 insertions(+), 203 deletions(-) diff --git a/Sources/Networking/RemoteConfigManager.swift b/Sources/Networking/RemoteConfigManager.swift index 22688d682d..0a6d58af4a 100644 --- a/Sources/Networking/RemoteConfigManager.swift +++ b/Sources/Networking/RemoteConfigManager.swift @@ -19,8 +19,8 @@ struct RemoteConfigBlobData: @unchecked Sendable { self.generation = generation } - func belongs(toGeneration generation: Int) -> Bool { - return self.generation == generation + func isSameGeneration(_ value: Int) -> Bool { + return self.generation == value } } @@ -235,7 +235,7 @@ final class NoOpRemoteConfigManager: RemoteConfigManagerType { } func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? { - return await self.blobDataSnapshot(for: topic, itemKey: itemKey)?.value + return nil } func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { @@ -404,7 +404,16 @@ final class RemoteConfigManager: RemoteConfigManagerType { } func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? { - return await self.blobDataSnapshot(for: topic, itemKey: itemKey)?.value + guard let itemSnapshot = await self.readCommittedStateSnapshot(refreshIfMissing: true, { + await self.committedTopic(topic)?[itemKey] + }), + let item = itemSnapshot.value else { + return nil + } + + return await self.readCommittedState(epoch: itemSnapshot.epoch) { + await self.blobData(for: item) + } } func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { diff --git a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift index dbc4ebfe23..d7eb5443f9 100644 --- a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift +++ b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift @@ -16,22 +16,18 @@ import Foundation class OfflineEntitlementsManager { private let deviceCache: DeviceCache - private let operationDispatcher: OperationDispatcher private let api: OfflineEntitlementsAPI private let systemInfo: SystemInfo private var productEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType? - private let productEntitlementMappingTasksLock = Lock() - private var productEntitlementMappingTasks: [UUID: Task] = [:] - private var completedProductEntitlementMappingTaskIDs: Set = [] + private let productEntitlementMappingLock = Lock() + private var productEntitlementMappingTask: Task? private var isClosed = false init(deviceCache: DeviceCache, - operationDispatcher: OperationDispatcher, api: OfflineEntitlementsAPI, systemInfo: SystemInfo) { self.deviceCache = deviceCache - self.operationDispatcher = operationDispatcher self.api = api self.systemInfo = systemInfo } @@ -41,109 +37,91 @@ class OfflineEntitlementsManager { self.productEntitlementMappingTopicProvider = provider } - func updateProductsEntitlementsCacheIfStale( - isAppBackgrounded: Bool, - completion: (@MainActor @Sendable (Result<(), Error>) -> Void)? - ) { + func updateProductsEntitlementsCacheIfStale(isAppBackgrounded: Bool) { guard #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), self.systemInfo.supportsOfflineEntitlements else { Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_unavailable) - - self.dispatchCompletionOnMainThreadIfPossible(completion, result: .failure(.notAvailable)) return } - guard self.deviceCache.isProductEntitlementMappingCacheStale else { - self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(())) - return - } - - guard self.productEntitlementMappingTasksLock.perform({ !self.isClosed }) else { return } + guard self.deviceCache.isProductEntitlementMappingCacheStale else { return } Logger.debug(Strings.offlineEntitlements.product_entitlement_mapping_stale_updating) guard let provider = self.productEntitlementMappingTopicProvider, provider.isAvailable else { - self.productEntitlementMappingTasksLock.perform { + self.productEntitlementMappingLock.perform { guard !self.isClosed else { return } - self.fetchLegacyProductEntitlementMapping( - isAppBackgrounded: isAppBackgrounded, - completion: completion - ) + self.fetchLegacyProductEntitlementMapping(isAppBackgrounded: isAppBackgrounded) } return } - let taskID = UUID() - let task = Task { [weak self] in - defer { self?.productEntitlementMappingTaskDidFinish(taskID) } - guard let self else { return } - - if let result = await provider.getProductEntitlementMapping(), - self.productEntitlementMappingTasksLock.perform({ - guard !self.isClosed, !Task.isCancelled else { return false } - guard result.useIfCurrent({ - self.handleProductEntitlementMappingBackendResult(with: $0) - }) else { return false } - self.dispatchCompletionOnMainThreadIfPossible(completion, result: .success(())) - return true - }) { - return - } + self.productEntitlementMappingLock.perform { + guard !self.isClosed, self.productEntitlementMappingTask == nil else { return } + + self.productEntitlementMappingTask = Task { [weak self, provider] in + let remoteResult = await provider.getProductEntitlementMapping() + guard let self else { return } - self.productEntitlementMappingTasksLock.perform { - guard !self.isClosed, !Task.isCancelled else { return } - self.fetchLegacyProductEntitlementMapping( - isAppBackgrounded: isAppBackgrounded, - completion: completion + await self.completeProductEntitlementMappingUpdate( + remoteResult: remoteResult, isAppBackgrounded: isAppBackgrounded ) } } - self.registerProductEntitlementMappingTask(task, id: taskID) } func close() { - let tasks = self.productEntitlementMappingTasksLock.perform { + let task = self.productEntitlementMappingLock.perform { self.isClosed = true - let tasks = Array(self.productEntitlementMappingTasks.values) - self.productEntitlementMappingTasks.removeAll() - self.completedProductEntitlementMappingTaskIDs.removeAll() - return tasks + let task = self.productEntitlementMappingTask + self.productEntitlementMappingTask = nil + return task } - tasks.forEach { $0.cancel() } + task?.cancel() } - private func registerProductEntitlementMappingTask(_ task: Task, id: UUID) { - let shouldCancel = self.productEntitlementMappingTasksLock.perform { - if self.isClosed { - self.completedProductEntitlementMappingTaskIDs.remove(id) + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func completeProductEntitlementMappingUpdate( + remoteResult: ProductEntitlementMappingResult?, + isAppBackgrounded: Bool + ) async { + if let remoteResult { + let didCacheRemoteResult = self.productEntitlementMappingLock.perform { + guard !self.isClosed, !Task.isCancelled else { return false } + guard remoteResult.useIfCurrent({ + self.handleProductEntitlementMappingBackendResult(with: $0) + }) else { return false } + + self.productEntitlementMappingTask = nil return true } - if self.completedProductEntitlementMappingTaskIDs.remove(id) != nil { - return false - } - - self.productEntitlementMappingTasks[id] = task - return false + if didCacheRemoteResult { return } } - if shouldCancel { - task.cancel() + + guard self.productEntitlementMappingLock.perform({ + !self.isClosed && !Task.isCancelled + }) else { + return } - } - private func productEntitlementMappingTaskDidFinish(_ id: UUID) { - self.productEntitlementMappingTasksLock.perform { - if self.productEntitlementMappingTasks.removeValue(forKey: id) == nil, !self.isClosed { - self.completedProductEntitlementMappingTaskIDs.insert(id) + let result = await self.fetchLegacyProductEntitlementMappingResult(isAppBackgrounded: isAppBackgrounded) + self.productEntitlementMappingLock.perform { + guard !self.isClosed, !Task.isCancelled else { return } + switch result { + case let .success(response): + self.handleProductEntitlementMappingBackendResult(with: response) + + case let .failure(error): + self.handleProductsEntitlementsUpdateError(error) } + + self.productEntitlementMappingTask = nil } } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - private func fetchLegacyProductEntitlementMapping( - isAppBackgrounded: Bool, - completion: (@MainActor @Sendable (Result<(), Error>) -> Void)? - ) { + private func fetchLegacyProductEntitlementMapping(isAppBackgrounded: Bool) { self.api.getProductEntitlementMapping(isAppBackgrounded: isAppBackgrounded) { result in switch result { case let .success(response): @@ -152,13 +130,16 @@ class OfflineEntitlementsManager { case let .failure(error): self.handleProductsEntitlementsUpdateError(error) } + } + } - self.dispatchCompletionOnMainThreadIfPossible( - completion, - result: result - .map { _ in () } - .mapError(Error.backend) - ) + private func fetchLegacyProductEntitlementMappingResult( + isAppBackgrounded: Bool + ) async -> Result { + return await withCheckedContinuation { continuation in + self.api.getProductEntitlementMapping(isAppBackgrounded: isAppBackgrounded) { + continuation.resume(returning: $0) + } } } @@ -174,18 +155,6 @@ class OfflineEntitlementsManager { } -extension OfflineEntitlementsManager { - - enum Error: Swift.Error { - - case backend(BackendError) - /// Offline entitlements require iOS 15+, and not available for custom entitlements computation - case notAvailable - - } - -} - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) private extension OfflineEntitlementsManager { @@ -200,18 +169,3 @@ private extension OfflineEntitlementsManager { } } - -private extension OfflineEntitlementsManager { - - func dispatchCompletionOnMainThreadIfPossible( - _ completion: (@MainActor @Sendable (Result) -> Void)?, - result: Result - ) { - if let completion = completion { - self.operationDispatcher.dispatchOnMainActor { - completion(result) - } - } - } - -} diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 443a503279..b85ed0b0bd 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -464,10 +464,11 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void localTransactionMetadataStore: localTransactionMetadataStore ) - let offlineEntitlementsManager = OfflineEntitlementsManager(deviceCache: deviceCache, - operationDispatcher: operationDispatcher, - api: backend.offlineEntitlements, - systemInfo: systemInfo) + let offlineEntitlementsManager = OfflineEntitlementsManager( + deviceCache: deviceCache, + api: backend.offlineEntitlements, + systemInfo: systemInfo + ) let customerInfoManager: CustomerInfoManager if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { @@ -2785,8 +2786,7 @@ private extension Purchases { isAppBackgrounded: isAppBackgrounded, completion: nil) self.offlineEntitlementsManager.updateProductsEntitlementsCacheIfStale( - isAppBackgrounded: isAppBackgrounded, - completion: nil + isAppBackgrounded: isAppBackgrounded ) } @@ -2833,8 +2833,7 @@ private extension Purchases { } self.offlineEntitlementsManager.updateProductsEntitlementsCacheIfStale( - isAppBackgrounded: isAppBackgrounded, - completion: nil + isAppBackgrounded: isAppBackgrounded ) } diff --git a/Tests/UnitTests/Mocks/MockOfflineEntitlementsManager.swift b/Tests/UnitTests/Mocks/MockOfflineEntitlementsManager.swift index 456969782c..be8a156875 100644 --- a/Tests/UnitTests/Mocks/MockOfflineEntitlementsManager.swift +++ b/Tests/UnitTests/Mocks/MockOfflineEntitlementsManager.swift @@ -18,7 +18,6 @@ class MockOfflineEntitlementsManager: OfflineEntitlementsManager { init() { super.init(deviceCache: MockDeviceCache(), - operationDispatcher: MockOperationDispatcher(), api: MockOfflineEntitlementsAPI(), systemInfo: MockSystemInfo(finishTransactions: false)) } @@ -28,10 +27,7 @@ class MockOfflineEntitlementsManager: OfflineEntitlementsManager { var invokedUpdateProductsEntitlementsCacheIfStaleParameters: Bool? var invokedUpdateProductsEntitlementsCacheIfStaleParametersList = [Bool]() - override func updateProductsEntitlementsCacheIfStale( - isAppBackgrounded: Bool, - completion: (@MainActor @Sendable (Result<(), Error>) -> Void)? - ) { + override func updateProductsEntitlementsCacheIfStale(isAppBackgrounded: Bool) { self.invokedUpdateProductsEntitlementsCacheIfStale = true self.invokedUpdateProductsEntitlementsCacheIfStaleCount += 1 self.invokedUpdateProductsEntitlementsCacheIfStaleParameters = isAppBackgrounded diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift index be19c485cc..8f221232f9 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift @@ -628,6 +628,68 @@ final class RemoteConfigManagerTests: TestCase { expect(self.blobStore.invokedReadRefs) == [ref] } + func testBlobDataRetainsLegacyBehaviorWhenConfigCommitsDuringResolution() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + let replacementData = #"{"id":"replacement"}"#.asData + let replacementRef = RCContainerTestData.blobRef(for: replacementData) + let response = """ + { + "domain": "app", + "manifest": "v2", + "active_topics": ["workflows"], + "topics": { "workflows": { "default": { "blob_ref": "\(replacementRef)" } } } + } + """ + let container = try Self.container(config: response) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + self.blobFetcher.ensureDownloadedHandler = { _ in + self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) + self.remoteConfigAPI.complete( + with: .success(.test(container: container)) + ) + } + + let resolvedData = await self.manager.blobData(for: .workflows, itemKey: "default") + + expect(resolvedData) == data + } + + func testBlobDataSnapshotRejectsConfigCommitDuringResolution() async throws { + let data = #"{"id":"workflow"}"#.asData + let ref = RCContainerTestData.blobRef(for: data) + let replacementData = #"{"id":"replacement"}"#.asData + let replacementRef = RCContainerTestData.blobRef(for: replacementData) + let response = """ + { + "domain": "app", + "manifest": "v2", + "active_topics": ["workflows"], + "topics": { "workflows": { "default": { "blob_ref": "\(replacementRef)" } } } + } + """ + let container = try Self.container(config: response) + self.diskCache.stubbedRead = Self.persisted( + manifest: "v1.1710000100.workflows:etag1", + topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) + ) + self.blobStore.stubbedReadDataByRef[ref] = data + self.blobFetcher.ensureDownloadedHandler = { _ in + self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) + self.remoteConfigAPI.complete( + with: .success(.test(container: container)) + ) + } + + let snapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") + + expect(snapshot).to(beNil()) + } + func testBlobDataSnapshotIsRejectedAfterIdentityInvalidation() async throws { let data = #"{"id":"workflow"}"#.asData let ref = RCContainerTestData.blobRef(for: data) @@ -3205,6 +3267,7 @@ private final class MockRemoteConfigBlobStore: RemoteConfigBlobStoreType { private final class MockRemoteConfigBlobFetcher: RemoteConfigBlobFetcherType { var stubbedEnsureDownloadedResult = true + var ensureDownloadedHandler: ((String) -> Void)? private let lock = Lock() private var _invokedEnsureDownloadedRefs: [String] = [] @@ -3222,6 +3285,7 @@ private final class MockRemoteConfigBlobFetcher: RemoteConfigBlobFetcherType { self.lock.perform { self._invokedEnsureDownloadedRefs.append(ref) } + self.ensureDownloadedHandler?(ref) return self.stubbedEnsureDownloadedResult } diff --git a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift index 6515dd8e91..1a014d6e6c 100644 --- a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift +++ b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift @@ -22,7 +22,6 @@ class BaseOfflineEntitlementsManagerTests: TestCase { var mockSystemInfo: MockSystemInfo! var mockDeviceCache: MockDeviceCache! - let mockOperationDispatcher = MockOperationDispatcher() var mockOfflineEntitlements: MockOfflineEntitlementsAPI! var mockProductEntitlementMappingTopicProvider: MockEntitlementMappingTopicProvider! @@ -42,7 +41,6 @@ class BaseOfflineEntitlementsManagerTests: TestCase { fileprivate func createManager() -> OfflineEntitlementsManager { let manager = OfflineEntitlementsManager(deviceCache: self.mockDeviceCache, - operationDispatcher: self.mockOperationDispatcher, api: self.mockOfflineEntitlements, systemInfo: self.mockSystemInfo) manager.setProductEntitlementMappingTopicProvider(self.mockProductEntitlementMappingTopicProvider) @@ -64,82 +62,78 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe self.mockSystemInfo = MockSystemInfo(finishTransactions: true, customEntitlementsComputation: true) self.manager = self.createManager() - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beFailure()) - expect(result?.error).to(matchError(OfflineEntitlementsManager.Error.notAvailable)) + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 0 } func testUpdateEntitlementsCacheForObserverMode() { self.mockSystemInfo = MockSystemInfo(finishTransactions: false) self.manager = self.createManager() - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beFailure()) - expect(result?.error).to(matchError(OfflineEntitlementsManager.Error.notAvailable)) + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 0 } func testUpdateProductsEntitlementsCacheDoesNotUpdateIfNotStale() { self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = false - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } - expect(result).to(beSuccess()) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 0 } - func testUpdateProductEntitlementMappingCacheUsesRemoteConfig() throws { + func testUpdateProductEntitlementMappingCacheUsesRemoteConfig() async throws { let mapping: ProductEntitlementMappingResponse = .init(products: [ "a": .init(identifier: "a", entitlements: ["pro_1", "pro_2"]) ]) self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockProductEntitlementMappingTopicProvider.stubbedProductEntitlementMapping = mapping - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) + + await expect(self.mockDeviceCache.cachedProductEntitlementMapping).toEventually(equal(mapping.toMapping())) + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 1 + } + + func testConcurrentUpdatesShareOneRemoteConfigTask() async { + let mapping: ProductEntitlementMappingResponse = .init(products: [ + "a": .init(identifier: "a", entitlements: ["pro"]) + ]) + self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true + self.mockProductEntitlementMappingTopicProvider.getProductEntitlementMappingHandler = { + try? await Task.sleep(nanoseconds: 50_000_000) + return ProductEntitlementMappingResult(response: mapping) { operation in + operation(mapping) + return true } } + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beSuccess()) - expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() - expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + await expect(self.mockDeviceCache.cachedProductEntitlementMapping).toEventually(equal(mapping.toMapping())) expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 1 } - func testUnavailableRemoteConfigMappingFallsBackToLegacyEndpoint() { + func testUnavailableRemoteConfigMappingFallsBackToLegacyEndpoint() async { let mapping: ProductEntitlementMappingResponse = .init(products: [ "legacy": .init(identifier: "legacy", entitlements: ["pro"]) ]) self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(mapping) - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beSuccess()) + await expect(self.mockDeviceCache.cachedProductEntitlementMapping).toEventually(equal(mapping.toMapping())) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true - expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() } - func testDisabledRemoteConfigFallsBackToLegacyEndpoint() { + func testDisabledRemoteConfigFallsBackToLegacyEndpoint() async { let remoteConfigManager = NoOpRemoteConfigManager() self.manager.setProductEntitlementMappingTopicProvider( ProductEntitlementMappingTopicProvider(manager: remoteConfigManager) @@ -150,15 +144,10 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(mapping) - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beSuccess()) + await expect(self.mockDeviceCache.cachedProductEntitlementMapping).toEventually(equal(mapping.toMapping())) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true - expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() } func testDisabledRemoteConfigEnqueuesLegacyRequestSynchronously() { @@ -167,12 +156,12 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe ) self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false, completion: nil) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true } - func testInvalidatedRemoteConfigMappingFallsBackToLegacyEndpoint() { + func testInvalidatedRemoteConfigMappingFallsBackToLegacyEndpoint() async { let remoteMapping: ProductEntitlementMappingResponse = .init(products: [ "remote": .init(identifier: "remote", entitlements: ["pro"]) ]) @@ -184,14 +173,10 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe self.mockProductEntitlementMappingTopicProvider.stubbedUseIfCurrent = false self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(legacyMapping) - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beSuccess()) - expect(self.mockDeviceCache.cachedProductEntitlementMapping) == legacyMapping.toMapping() + await expect(self.mockDeviceCache.cachedProductEntitlementMapping) + .toEventually(equal(legacyMapping.toMapping())) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true } @@ -204,7 +189,7 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe return nil } - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false, completion: nil) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) await Task.yield() self.manager.close() await Task.yield() @@ -213,7 +198,7 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false } - func testUpdateProductEntitlementMappingCacheUpdatesIfStaleSuccess() { + func testUpdateProductEntitlementMappingCacheUpdatesIfStaleSuccess() async { let mapping: ProductEntitlementMappingResponse = .init(products: [ "a": .init(identifier: "a", entitlements: ["pro_1", "pro_2"]) ]) @@ -222,34 +207,26 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(mapping) - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: isAppBackgrounded) { - completion($0) - } - } - expect(result).to(beSuccess()) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: isAppBackgrounded) - expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true + await expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping).toEventually(beTrue()) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMappingCount) == 1 expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMappingParameter) == isAppBackgrounded + expect(self.mockDeviceCache.cachedProductEntitlementMapping) == mapping.toMapping() } - func testUpdateProductEntitlementMappingCacheDoesNotUpdateIfStaleFailure() { + func testUpdateProductEntitlementMappingCacheDoesNotUpdateIfStaleFailure() async { let expectedError: BackendError = .missingAppUserID() + let cachedMapping = self.mockDeviceCache.cachedProductEntitlementMapping self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .failure(expectedError) - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } - expect(result).to(beFailure()) - expect(result?.error).to(matchError(OfflineEntitlementsManager.Error.backend(expectedError))) + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true + await expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping).toEventually(beTrue()) expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMappingCount) == 1 + expect(self.mockDeviceCache.cachedProductEntitlementMapping) == cachedMapping } func testShouldComputeOfflineCustomerInfo() { @@ -394,15 +371,11 @@ class OfflineEntitlementsManagerUnavailableTests: BaseOfflineEntitlementsManager } } - func testUpdateEntitlementsCacheReturnsNotAvailable() { - let result = waitUntilValue { completion in - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) { - completion($0) - } - } + func testUpdateEntitlementsCacheDoesNothingWhenUnavailable() { + self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - expect(result).to(beFailure()) - expect(result?.error).to(matchError(OfflineEntitlementsManager.Error.notAvailable)) + expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == false + expect(self.mockProductEntitlementMappingTopicProvider.invokedGetProductEntitlementMappingCount) == 0 } } diff --git a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift index 7c6c4e5efd..c537b35e04 100644 --- a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift @@ -830,7 +830,7 @@ final class MockRemoteConfigManager: RemoteConfigManagerType { return self.stateLock.perform { guard !self.isDisabledStorage, !self.isClosed, - snapshot.belongs(toGeneration: self.configGenerationStorage) else { + snapshot.isSameGeneration(self.configGenerationStorage) else { return false } From 9bc15fbffae98f73c9c6b8b41088d83817709c73 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 09:49:56 +0200 Subject: [PATCH 8/9] Categorize PEM decoding under offline entitlements --- Sources/Logging/Strings/OfflineEntitlementsStrings.swift | 4 ++++ Sources/Logging/Strings/RemoteConfigStrings.swift | 3 --- .../Networking/ProductEntitlementMappingTopicProvider.swift | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Logging/Strings/OfflineEntitlementsStrings.swift b/Sources/Logging/Strings/OfflineEntitlementsStrings.swift index 182ed6a1e0..54dbe4af83 100644 --- a/Sources/Logging/Strings/OfflineEntitlementsStrings.swift +++ b/Sources/Logging/Strings/OfflineEntitlementsStrings.swift @@ -24,6 +24,7 @@ enum OfflineEntitlementsStrings { case product_entitlement_mapping_updated_from_network case product_entitlement_mapping_unavailable case product_entitlement_mapping_fetching_error(BackendError) + case product_entitlement_mapping_remote_config_decoding_error(Error) case found_unverified_transactions_in_sk2(transactionID: UInt64, Error) case computing_offline_customer_info_with_no_entitlement_mapping @@ -60,6 +61,9 @@ extension OfflineEntitlementsStrings: LogMessage { case let .product_entitlement_mapping_fetching_error(error): return "Failed updating ProductEntitlementMapping from network: \(error.localizedDescription)" + case let .product_entitlement_mapping_remote_config_decoding_error(error): + return "Failed decoding ProductEntitlementMapping from Remote Config: \(error.localizedDescription)" + case let .found_unverified_transactions_in_sk2(transactionID, error): return """ Found an unverified transaction. It will be ignored and will not be a part of CustomerInfo. diff --git a/Sources/Logging/Strings/RemoteConfigStrings.swift b/Sources/Logging/Strings/RemoteConfigStrings.swift index 9e86d47201..ff7af59736 100644 --- a/Sources/Logging/Strings/RemoteConfigStrings.swift +++ b/Sources/Logging/Strings/RemoteConfigStrings.swift @@ -28,7 +28,6 @@ enum RemoteConfigStrings { case notModified case prefetchEnqueued(Int) case prefetchingBlobCount(Int) - case productEntitlementMappingDecodeFailed(Error) case receivedConfiguration(activeTopics: [String], changedTopics: [String]) case refreshing(domain: String, manifestPresent: Bool, isAppBackgrounded: Bool) case refreshFailed(BackendError) @@ -89,8 +88,6 @@ extension RemoteConfigStrings: LogMessage { return "Enqueued \(count) remote config blob prefetch downloads." case let .prefetchingBlobCount(count): return "Prefetching \(count) remote config blobs requested by the latest configuration." - case let .productEntitlementMappingDecodeFailed(error): - return "Failed to decode product_entitlement_mapping.default: \(error.localizedDescription)" case let .receivedConfiguration(activeTopics, changedTopics): return "Received remote config with \(activeTopics.count) active topics " + "(\(activeTopics.sorted().joined(separator: ", "))) and \(changedTopics.count) changed topics " + diff --git a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift index 5cce2e6309..e5dd9f5c1a 100644 --- a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift +++ b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift @@ -67,7 +67,7 @@ final class ProductEntitlementMappingTopicProvider: EntitlementMappingTopicProvi manager.useIfCurrent(blobData) { _ in operation(response) } } } catch { - Logger.error(Strings.remoteConfig.productEntitlementMappingDecodeFailed(error)) + Logger.error(Strings.offlineEntitlements.product_entitlement_mapping_remote_config_decoding_error(error)) return nil } } From 3c23fabe4fdd3d8917a42759282492d57762a596 Mon Sep 17 00:00:00 2001 From: Rick van der Linden Date: Thu, 23 Jul 2026 15:05:21 +0200 Subject: [PATCH 9/9] Simplify remote config PEM reads --- ...oductEntitlementMappingTopicProvider.swift | 32 +---- Sources/Networking/RemoteConfigManager.swift | 79 ---------- .../OfflineEntitlementsManager.swift | 7 +- .../RemoteConfigIntegrationTests.swift | 2 +- .../RemoteConfigManagerTests.swift | 135 ------------------ .../OfflineEntitlementsManagerTests.swift | 70 +-------- .../Purchases/BasePurchasesTests.swift | 34 ----- 7 files changed, 13 insertions(+), 346 deletions(-) diff --git a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift index e5dd9f5c1a..e8da3d2293 100644 --- a/Sources/Networking/ProductEntitlementMappingTopicProvider.swift +++ b/Sources/Networking/ProductEntitlementMappingTopicProvider.swift @@ -12,26 +12,7 @@ protocol EntitlementMappingTopicProviderType: AnyObject { var isAvailable: Bool { get } /// Returns the mapping when its remote-config blob is available and decodes successfully. - func getProductEntitlementMapping() async -> ProductEntitlementMappingResult? - -} - -struct ProductEntitlementMappingResult: @unchecked Sendable { - - let response: ProductEntitlementMappingResponse - private let useIfCurrentOperation: ((ProductEntitlementMappingResponse) -> Void) -> Bool - - init( - response: ProductEntitlementMappingResponse, - useIfCurrent: @escaping ((ProductEntitlementMappingResponse) -> Void) -> Bool - ) { - self.response = response - self.useIfCurrentOperation = useIfCurrent - } - - func useIfCurrent(_ operation: (ProductEntitlementMappingResponse) -> Void) -> Bool { - return self.useIfCurrentOperation(operation) - } + func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? } @@ -51,21 +32,18 @@ final class ProductEntitlementMappingTopicProvider: EntitlementMappingTopicProvi return !manager.isDisabled } - func getProductEntitlementMapping() async -> ProductEntitlementMappingResult? { + func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? { guard let manager = self.manager, - let blobData = await manager.blobDataSnapshot( + let blobData = await manager.blobData( for: .productEntitlementMapping, itemKey: Self.itemKey ) else { return nil } do { - let response = try JSONDecoder.default.decode( + return try JSONDecoder.default.decode( ProductEntitlementMappingResponse.self, - from: blobData.value + from: blobData ) - return ProductEntitlementMappingResult(response: response) { operation in - manager.useIfCurrent(blobData) { _ in operation(response) } - } } catch { Logger.error(Strings.offlineEntitlements.product_entitlement_mapping_remote_config_decoding_error(error)) return nil diff --git a/Sources/Networking/RemoteConfigManager.swift b/Sources/Networking/RemoteConfigManager.swift index 0a6d58af4a..f22cd839ec 100644 --- a/Sources/Networking/RemoteConfigManager.swift +++ b/Sources/Networking/RemoteConfigManager.swift @@ -9,22 +9,6 @@ import Foundation // swiftlint:disable file_length -struct RemoteConfigBlobData: @unchecked Sendable { - - let value: Value - fileprivate let generation: Int - - init(value: Value, generation: Int) { - self.value = value - self.generation = generation - } - - func isSameGeneration(_ value: Int) -> Bool { - return self.generation == value - } - -} - protocol RemoteConfigManagerType: AnyObject { /// Whether remote config should be ignored for the current manager lifetime. @@ -50,12 +34,6 @@ protocol RemoteConfigManagerType: AnyObject { /// wait for an in-flight refresh or trigger one foreground refresh before resolving the blob on demand. func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? - /// Returns blob bytes together with the committed config generation that produced them. - func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? - - /// Runs `operation` atomically only if `snapshot` still belongs to the current committed config. - func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool - /// Decodes a blob payload as a concrete `Decodable` type. /// /// Returns `nil` when the item or blob is unavailable. Throws when bytes are available but cannot be decoded. @@ -238,14 +216,6 @@ final class NoOpRemoteConfigManager: RemoteConfigManagerType { return nil } - func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { - return nil - } - - func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { - return false - } - func blobData( for topic: RemoteConfigTopic, itemKey: String, @@ -416,55 +386,6 @@ final class RemoteConfigManager: RemoteConfigManagerType { } } - func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { - guard let itemSnapshot = await self.readCommittedStateSnapshot(refreshIfMissing: true, { - await self.committedTopic(topic)?[itemKey] - }), - let item = itemSnapshot.value, - let blobRef = item.blobRef else { - return nil - } - - let generation = self.lock.perform { - guard !self.isDisabledInternal, - !self.isClosed, - self.epoch == itemSnapshot.epoch, - self.diskCache.topic(topic)?[itemKey]?.blobRef == blobRef else { - return nil as Int? - } - - return self.generation - } - guard let generation else { return nil } - - guard let data = await self.readCommittedState(epoch: itemSnapshot.epoch, { - await self.blobData(for: item) - }) else { return nil } - - return self.lock.perform { - guard !self.isDisabledInternal, - !self.isClosed, - self.generation == generation else { - return nil - } - - return RemoteConfigBlobData(value: data, generation: generation) - } - } - - func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { - return self.lock.perform { - guard !self.isDisabledInternal, - !self.isClosed, - self.generation == snapshot.generation else { - return false - } - - operation(snapshot.value) - return true - } - } - func blobData( for topic: RemoteConfigTopic, itemKey: String, diff --git a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift index d7eb5443f9..0705532fbc 100644 --- a/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift +++ b/Sources/OfflineEntitlements/OfflineEntitlementsManager.swift @@ -83,16 +83,13 @@ class OfflineEntitlementsManager { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) private func completeProductEntitlementMappingUpdate( - remoteResult: ProductEntitlementMappingResult?, + remoteResult: ProductEntitlementMappingResponse?, isAppBackgrounded: Bool ) async { if let remoteResult { let didCacheRemoteResult = self.productEntitlementMappingLock.perform { guard !self.isClosed, !Task.isCancelled else { return false } - guard remoteResult.useIfCurrent({ - self.handleProductEntitlementMappingBackendResult(with: $0) - }) else { return false } - + self.handleProductEntitlementMappingBackendResult(with: remoteResult) self.productEntitlementMappingTask = nil return true } diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift index be7028258a..220aa223c4 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigIntegrationTests.swift @@ -466,7 +466,7 @@ final class RemoteConfigIntegrationTests: TestCase { let result = await provider.getProductEntitlementMapping() let requestedURLs = await self.downloader.requestedURLs() - expect(result?.response) == mapping + expect(result) == mapping expect(self.remoteConfigRequestCount) == 1 expect(requestedURLs).to(beEmpty()) } diff --git a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift index 8f221232f9..d3e1b4c569 100644 --- a/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift +++ b/Tests/UnitTests/Networking/RemoteConfig/RemoteConfigManagerTests.swift @@ -659,141 +659,6 @@ final class RemoteConfigManagerTests: TestCase { expect(resolvedData) == data } - func testBlobDataSnapshotRejectsConfigCommitDuringResolution() async throws { - let data = #"{"id":"workflow"}"#.asData - let ref = RCContainerTestData.blobRef(for: data) - let replacementData = #"{"id":"replacement"}"#.asData - let replacementRef = RCContainerTestData.blobRef(for: replacementData) - let response = """ - { - "domain": "app", - "manifest": "v2", - "active_topics": ["workflows"], - "topics": { "workflows": { "default": { "blob_ref": "\(replacementRef)" } } } - } - """ - let container = try Self.container(config: response) - self.diskCache.stubbedRead = Self.persisted( - manifest: "v1.1710000100.workflows:etag1", - topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) - ) - self.blobStore.stubbedReadDataByRef[ref] = data - self.blobFetcher.ensureDownloadedHandler = { _ in - self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) - self.remoteConfigAPI.complete( - with: .success(.test(container: container)) - ) - } - - let snapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") - - expect(snapshot).to(beNil()) - } - - func testBlobDataSnapshotIsRejectedAfterIdentityInvalidation() async throws { - let data = #"{"id":"workflow"}"#.asData - let ref = RCContainerTestData.blobRef(for: data) - self.diskCache.stubbedRead = Self.persisted( - manifest: "v1.1710000100.workflows:etag1", - topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) - ) - self.blobStore.stubbedReadDataByRef[ref] = data - let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") - let snapshot = try XCTUnwrap(maybeSnapshot) - - self.manager.clearCache(forAppUserID: "new-user") - - var invoked = false - expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false - expect(invoked) == false - } - - func testUseIfCurrentExecutesExactlyOnceForCurrentBlobSnapshot() async throws { - let data = #"{"id":"workflow"}"#.asData - let ref = RCContainerTestData.blobRef(for: data) - self.diskCache.stubbedRead = Self.persisted( - manifest: "v1.1710000100.workflows:etag1", - topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) - ) - self.blobStore.stubbedReadDataByRef[ref] = data - let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") - let snapshot = try XCTUnwrap(maybeSnapshot) - var invocationCount = 0 - - let used = self.manager.useIfCurrent(snapshot) { value in - invocationCount += 1 - expect(value) == data - } - - expect(used) == true - expect(invocationCount) == 1 - } - - func testUseIfCurrentRejectsBlobSnapshotAfterNewerConfigCommit() async throws { - let data = #"{"id":"workflow"}"#.asData - let ref = RCContainerTestData.blobRef(for: data) - self.diskCache.stubbedRead = Self.persisted( - manifest: "v1.1710000100.workflows:etag1", - topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) - ) - self.blobStore.stubbedReadDataByRef[ref] = data - let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") - let snapshot = try XCTUnwrap(maybeSnapshot) - let replacementData = #"{"id":"replacement"}"#.asData - let replacementRef = RCContainerTestData.blobRef(for: replacementData) - let response = """ - { - "domain": "app", - "manifest": "v2", - "active_topics": ["workflows"], - "topics": { "workflows": { "default": { "blob_ref": "\(replacementRef)" } } } - } - """ - self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) - self.remoteConfigAPI.complete( - with: .success(.test(container: try Self.container(config: response))) - ) - var invoked = false - - expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false - expect(invoked) == false - } - - func testUseIfCurrentRejectsBlobSnapshotAfterRemoteConfigIsDisabled() async throws { - let data = #"{"id":"workflow"}"#.asData - let ref = RCContainerTestData.blobRef(for: data) - self.diskCache.stubbedRead = Self.persisted( - manifest: "v1.1710000100.workflows:etag1", - topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) - ) - self.blobStore.stubbedReadDataByRef[ref] = data - let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") - let snapshot = try XCTUnwrap(maybeSnapshot) - self.manager.refreshRemoteConfig(fetchContext: .foreground, isAppBackgrounded: false) - self.remoteConfigAPI.complete(with: .failure(Self.backendError(statusCode: .forbidden))) - var invoked = false - - expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false - expect(invoked) == false - } - - func testUseIfCurrentRejectsBlobSnapshotAfterManagerCloses() async throws { - let data = #"{"id":"workflow"}"#.asData - let ref = RCContainerTestData.blobRef(for: data) - self.diskCache.stubbedRead = Self.persisted( - manifest: "v1.1710000100.workflows:etag1", - topics: .init(entries: ["workflows": ["default": .init(blobRef: ref)]]) - ) - self.blobStore.stubbedReadDataByRef[ref] = data - let maybeSnapshot = await self.manager.blobDataSnapshot(for: .workflows, itemKey: "default") - let snapshot = try XCTUnwrap(maybeSnapshot) - self.manager.close() - var invoked = false - - expect(self.manager.useIfCurrent(snapshot) { _ in invoked = true }) == false - expect(invoked) == false - } - func testEnsureBlobsDownloadedDelegatesToBlobFetcher() async { let refs = ["ref-1", "ref-2"] diff --git a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift index 1a014d6e6c..7bd39b71af 100644 --- a/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift +++ b/Tests/UnitTests/OfflineEntitlements/OfflineEntitlementsManagerTests.swift @@ -108,10 +108,7 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockProductEntitlementMappingTopicProvider.getProductEntitlementMappingHandler = { try? await Task.sleep(nanoseconds: 50_000_000) - return ProductEntitlementMappingResult(response: mapping) { operation in - operation(mapping) - return true - } + return mapping } self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) @@ -161,25 +158,6 @@ class OfflineEntitlementsManagerAvailableTests: BaseOfflineEntitlementsManagerTe expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true } - func testInvalidatedRemoteConfigMappingFallsBackToLegacyEndpoint() async { - let remoteMapping: ProductEntitlementMappingResponse = .init(products: [ - "remote": .init(identifier: "remote", entitlements: ["pro"]) - ]) - let legacyMapping: ProductEntitlementMappingResponse = .init(products: [ - "legacy": .init(identifier: "legacy", entitlements: ["pro"]) - ]) - self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true - self.mockProductEntitlementMappingTopicProvider.stubbedProductEntitlementMapping = remoteMapping - self.mockProductEntitlementMappingTopicProvider.stubbedUseIfCurrent = false - self.mockOfflineEntitlements.stubbedGetProductEntitlementMappingResult = .success(legacyMapping) - - self.manager.updateProductsEntitlementsCacheIfStale(isAppBackgrounded: false) - - await expect(self.mockDeviceCache.cachedProductEntitlementMapping) - .toEventually(equal(legacyMapping.toMapping())) - expect(self.mockOfflineEntitlements.invokedGetProductEntitlementMapping) == true - } - func testCloseCancelsRemoteConfigReadWithoutStartingLegacyFallback() async { self.mockDeviceCache.stubbedIsProductEntitlementMappingCacheStale = true self.mockProductEntitlementMappingTopicProvider.getProductEntitlementMappingHandler = { @@ -272,7 +250,7 @@ final class EntitlementMappingTopicProviderTests: TestCase { let result = await self.provider.getProductEntitlementMapping() - expect(result?.response) == mapping + expect(result) == mapping expect(self.remoteConfigManager.invokedBlobDataParameters.count) == 1 expect(self.remoteConfigManager.invokedBlobDataParameters.first?.topic) == .productEntitlementMapping @@ -303,59 +281,21 @@ final class EntitlementMappingTopicProviderTests: TestCase { expect(result).to(beNil()) } - func testResultIsRejectedWhenRemoteConfigChangesBeforeUse() async throws { - let mapping: ProductEntitlementMappingResponse = .init(products: [ - "monthly": .init(identifier: "monthly", entitlements: ["pro"]) - ]) - self.remoteConfigManager.stubbedBlobData[.productEntitlementMapping] = [ - "default": try JSONEncoder.default.encode(mapping) - ] - let result = await self.provider.getProductEntitlementMapping() - - self.remoteConfigManager.clearCache() - - expect(result?.useIfCurrent { _ in }).to(beFalse()) - } - - func testReturnsNilWhenRemoteConfigChangesDuringBlobRead() async throws { - let mapping: ProductEntitlementMappingResponse = .init(products: [ - "monthly": .init(identifier: "monthly", entitlements: ["pro"]) - ]) - self.remoteConfigManager.stubbedBlobData[.productEntitlementMapping] = [ - "default": try JSONEncoder.default.encode(mapping) - ] - self.remoteConfigManager.shouldStoreBlobDataCompletion = true - - async let result = self.provider.getProductEntitlementMapping() - await expect(self.remoteConfigManager.invokedBlobDataParameters).toEventuallyNot(beEmpty()) - self.remoteConfigManager.clearCache() - self.remoteConfigManager.completeStoredBlobReads() - - let resolvedResult = await result - expect(resolvedResult).to(beNil()) - } - } final class MockEntitlementMappingTopicProvider: EntitlementMappingTopicProviderType { var isAvailable = true var stubbedProductEntitlementMapping: ProductEntitlementMappingResponse? - var stubbedUseIfCurrent = true - var getProductEntitlementMappingHandler: (() async -> ProductEntitlementMappingResult?)? + var getProductEntitlementMappingHandler: (() async -> ProductEntitlementMappingResponse?)? private(set) var invokedGetProductEntitlementMappingCount = 0 - func getProductEntitlementMapping() async -> ProductEntitlementMappingResult? { + func getProductEntitlementMapping() async -> ProductEntitlementMappingResponse? { self.invokedGetProductEntitlementMappingCount += 1 if let handler = self.getProductEntitlementMappingHandler { return await handler() } - guard let mapping = self.stubbedProductEntitlementMapping else { return nil } - return ProductEntitlementMappingResult(response: mapping) { operation in - guard self.stubbedUseIfCurrent else { return false } - operation(mapping) - return true - } + return self.stubbedProductEntitlementMapping } } diff --git a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift index c537b35e04..0a2db49233 100644 --- a/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift @@ -805,40 +805,6 @@ final class MockRemoteConfigManager: RemoteConfigManagerType { } } - func blobDataSnapshot(for topic: RemoteConfigTopic, itemKey: String) async -> RemoteConfigBlobData? { - let generation = self.stateLock.perform { - guard !self.isDisabledStorage, !self.isClosed else { return nil as Int? } - return self.configGenerationStorage - } - guard let generation, - let data = await self.blobData(for: topic, itemKey: itemKey) else { - return nil - } - - return self.stateLock.perform { - guard !self.isDisabledStorage, - !self.isClosed, - self.configGenerationStorage == generation else { - return nil - } - - return RemoteConfigBlobData(value: data, generation: generation) - } - } - - func useIfCurrent(_ snapshot: RemoteConfigBlobData, operation: (T) -> Void) -> Bool { - return self.stateLock.perform { - guard !self.isDisabledStorage, - !self.isClosed, - snapshot.isSameGeneration(self.configGenerationStorage) else { - return false - } - - operation(snapshot.value) - return true - } - } - /// Resumes every held blob read with its key's stubbed data and stops holding subsequent /// reads, so sequential read chains (like `mergeItemsBlobData`'s loop) run to completion. func completeStoredBlobReads() {