Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@
75BB98FE2E336C220001DD1A /* ProductsManagerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75BB98FD2E336C220001DD1A /* ProductsManagerFactory.swift */; };
75BE27EA2DFC8C6A00C9440E /* PreferredLocalesProvider+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75BE27E92DFC8C6A00C9440E /* PreferredLocalesProvider+Mock.swift */; };
75BE27EB2DFC8C6A00C9440E /* PreferredLocalesProvider+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75BE27E92DFC8C6A00C9440E /* PreferredLocalesProvider+Mock.swift */; };
75D96D2C2EEC8CCC00F261C1 /* LocalTransactionDetailsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D96D2B2EEC8CCC00F261C1 /* LocalTransactionDetailsStorage.swift */; };
75D9DE072D79FC0E0068554F /* testEncoding.1.json in Resources */ = {isa = PBXBuildFile; fileRef = 75D9DE022D79FC0E0068554F /* testEncoding.1.json */; };
75D9DE082D79FC0E0068554F /* DiagnosticsEventEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D9DE052D79FC0E0068554F /* DiagnosticsEventEncodingTests.swift */; };
75E61CB62E2F9EF30034B41C /* MockTestStorePurchaseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7581A92C2E2EB27100D0C3DE /* MockTestStorePurchaseHandler.swift */; };
Expand Down Expand Up @@ -2449,6 +2450,7 @@
75BB98FA2E336C070001DD1A /* ProductsManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManagerType.swift; sourceTree = "<group>"; };
75BB98FD2E336C220001DD1A /* ProductsManagerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManagerFactory.swift; sourceTree = "<group>"; };
75BE27E92DFC8C6A00C9440E /* PreferredLocalesProvider+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferredLocalesProvider+Mock.swift"; sourceTree = "<group>"; };
75D96D2B2EEC8CCC00F261C1 /* LocalTransactionDetailsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTransactionDetailsStorage.swift; sourceTree = "<group>"; };
75D9DE022D79FC0E0068554F /* testEncoding.1.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = testEncoding.1.json; sourceTree = "<group>"; };
75D9DE052D79FC0E0068554F /* DiagnosticsEventEncodingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiagnosticsEventEncodingTests.swift; sourceTree = "<group>"; };
75FCD4CC2E37FBE100036C02 /* SimulatedStoreMockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatedStoreMockData.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5906,6 +5908,7 @@
2D9F4A5426C30CA800B07B43 /* PurchasesOrchestrator.swift */,
4F8038322A1EA7C300D21039 /* TransactionPoster.swift */,
16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */,
75D96D2B2EEC8CCC00F261C1 /* LocalTransactionDetailsStorage.swift */,
);
path = Purchases;
sourceTree = "<group>";
Expand Down Expand Up @@ -6786,6 +6789,7 @@
5766AA3E283C750300FA6091 /* Operators+Extensions.swift in Sources */,
903A05B92EB3D96E009B9CE4 /* PostAdEventsOperation.swift in Sources */,
1ED4CA532CC154E00021AB8F /* WebPurchaseRedemptionHelper.swift in Sources */,
75D96D2C2EEC8CCC00F261C1 /* LocalTransactionDetailsStorage.swift in Sources */,
4FFCED892AA941D200118EF4 /* FeatureEventHTTPRequestPath.swift in Sources */,
FDAADFD12BE2B87000BD1659 /* StoreKit2ObserverModePurchaseDetector.swift in Sources */,
FD3A85FC2DDE7532005F3C79 /* VirtualCurrencies.swift in Sources */,
Expand Down
2 changes: 0 additions & 2 deletions Sources/Caching/DeviceCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ class DeviceCache {
private var userDefaultsObserver: NSObjectProtocol?

private var offeringsCachePreferredLocales: [String] = []
private let cacheURL: URL?

init(systemInfo: SystemInfo,
userDefaults: UserDefaults,
Expand All @@ -44,7 +43,6 @@ class DeviceCache {
self.userDefaults = .init(userDefaults: userDefaults)
self._cachedAppUserID = .init(userDefaults.string(forKey: CacheKeys.appUserDefaults))
self._cachedLegacyAppUserID = .init(userDefaults.string(forKey: CacheKeys.legacyGeneratedAppUserDefaults))
self.cacheURL = fileManager.createDocumentDirectoryIfNeeded(basePath: Self.defaultBasePath)
self.largeItemCache = .init(cache: fileManager, basePath: Self.defaultBasePath)

Logger.verbose(Strings.purchase.device_cache_init(self))
Expand Down
40 changes: 29 additions & 11 deletions Sources/Misc/Concurrency/SynchronizedLargeItemCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,15 @@ internal final class SynchronizedLargeItemCache {
self.documentURL = cache.createDocumentDirectoryIfNeeded(basePath: basePath)
}

private func read<T>(_ action: (LargeItemCacheType, URL?) throws -> T) rethrows -> T {
@inline(__always)
private func withLock<T>(
_ action: (_ cache: LargeItemCacheType, _ documentURL: URL?) throws -> T
) rethrows -> T {
return try self.lock.perform {
return try action(self.cache, self.documentURL)
}
}

private func write(_ action: (LargeItemCacheType, URL?) throws -> Void) rethrows {
return try self.lock.perform {
try action(self.cache, self.documentURL)
}
}

/// Get the file URL for a specific cache key
private func getFileURL(for key: DeviceCacheKeyType) -> URL? {
guard let documentURL = self.documentURL else {
Expand All @@ -59,7 +56,7 @@ internal final class SynchronizedLargeItemCache {
}

do {
try self.write { cache, _ in
try self.withLock { cache, _ in
try cache.saveData(data, to: fileURL)
}
return true
Expand All @@ -75,7 +72,7 @@ internal final class SynchronizedLargeItemCache {
return nil
}

return self.read { cache, _ in
return self.withLock { cache, _ in
guard let data = try? cache.loadFile(at: fileURL) else {
return nil
}
Expand All @@ -90,8 +87,29 @@ internal final class SynchronizedLargeItemCache {
return
}

self.write { _, _ in
try? self.cache.remove(fileURL)
self.withLock { cache, _ in
try? cache.remove(fileURL)
}
}

/// Move a cached item from one key to another
@discardableResult
func moveObject(fromKey oldKey: DeviceCacheKeyType, toKey newKey: DeviceCacheKeyType) -> Bool {
guard let oldFileURL = self.getFileURL(for: oldKey),
let newFileURL = self.getFileURL(for: newKey) else {
return false
}

do {
return try self.withLock { cache, _ in
let data = try cache.loadFile(at: oldFileURL)
try cache.saveData(data, to: newFileURL)
try cache.remove(oldFileURL)
return true
}
} catch {
Logger.error("Failed to move cached item: \(error)")
return false
}
}

Expand Down
5 changes: 3 additions & 2 deletions Sources/Purchasing/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,5 +213,6 @@ extension Package: Identifiable {
}

extension Package: Sendable {}
extension PresentedOfferingContext: Sendable {}
extension PresentedOfferingContext.TargetingContext: Sendable {}
// TODO: Breaking change?
extension PresentedOfferingContext: Sendable, Codable {}
extension PresentedOfferingContext.TargetingContext: Sendable, Codable {}
117 changes: 117 additions & 0 deletions Sources/Purchasing/Purchases/LocalTransactionDetailsStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// LocalTransactionDetailsStorage.swift
//
// Created by Antonio Pallares on 15/12/25.
//

import Foundation

/// Metadata stored locally for a transaction to preserve context across sessions.
internal struct LocalTransactionDetails: Codable, Sendable {

/// The offering context when the transaction was initiated.
let presentedOfferingContext: PresentedOfferingContext?

/// The paywall event data when the transaction was initiated.
let paywallPostReceiptData: PaywallEvent?

/// Whether purchases are completed by RevenueCat or the app (observer mode equivalent).
let observerMode: Bool

/// The product identifier (used for SK1 pending transaction fallback).
let productIdentifier: String

init(
presentedOfferingContext: PresentedOfferingContext?,
paywallPostReceiptData: PaywallEvent?,
observerMode: Bool,
productIdentifier: String
) {
self.presentedOfferingContext = presentedOfferingContext
self.paywallPostReceiptData = paywallPostReceiptData
self.observerMode = observerMode
self.productIdentifier = productIdentifier
}
}

/// Cache for storing local transaction details persistently on disk.
final class LocalTransactionDetailsStorage: Sendable {

private let cache: SynchronizedLargeItemCache

init(fileManager: LargeItemCacheType = FileManager.default) {
self.cache = SynchronizedLargeItemCache(cache: fileManager, basePath: "revenuecat.localTransactionDetails")
}

/// Cache key for local transaction details.
struct Key: DeviceCacheKeyType {
private let identifier: String

init(transactionIdentifier: String) {
self.identifier = "transaction.\(transactionIdentifier)"
}

init(productIdentifier: String) {
self.identifier = "product.\(productIdentifier)"
}

var rawValue: String {
return "transactionDetails.\(identifier)"
}
}

/// Store transaction details for a given transaction ID.
func store(details: LocalTransactionDetails, forTransactionID transactionID: String) {
// TODO: What if there's already details stored for the transactionID? Should we remove them?
let key = Key(transactionIdentifier: transactionID)
self.cache.set(codable: details, forKey: key)
}

/// Store transaction details for a given product ID (used for SK1 pending transactions).
func store(details: LocalTransactionDetails, forProductID productID: String) {
// TODO: What if there's already details stored for the productID? Should we remove them?
let key = Key(productIdentifier: productID)
self.cache.set(codable: details, forKey: key)
}

/// Retrieve transaction details for a given transaction ID.
func retrieve(forTransactionID transactionID: String) -> LocalTransactionDetails? {
let key = Key(transactionIdentifier: transactionID)
return self.cache.value(forKey: key)
}

/// Retrieve transaction details for a given product ID (used for SK1 pending transactions).
func retrieve(forProductID productID: String) -> LocalTransactionDetails? {
let key = Key(productIdentifier: productID)
return self.cache.value(forKey: key)
}

/// Remove transaction details for a given transaction ID.
func remove(forTransactionID transactionID: String) {
let key = Key(transactionIdentifier: transactionID)
self.cache.removeObject(forKey: key)
}

/// Remove transaction details for a given product ID.
func remove(forProductID productID: String) {
let key = Key(productIdentifier: productID)
self.cache.removeObject(forKey: key)
}

/// Migrate details from product ID to transaction ID (for SK1 pending → purchased transition).
// TODO: What about SK2 pending transactions?
func migrate(fromProductID productID: String, toTransactionID transactionID: String) {
let oldKey = Key(productIdentifier: productID)
let newKey = Key(transactionIdentifier: transactionID)
self.cache.moveObject(fromKey: oldKey, toKey: newKey)
}
}

Loading