From 0bda7f208f27f7a9887816caacd54595ef92876c Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 14:58:22 +0400 Subject: [PATCH 01/10] feat(sdk): internal multi-instance registry and instance resolvers Add a process-wide SdkRegistry that tracks PersonalizationSDK instances by shop_id, alongside pure InstanceResolver / PushTargetResolver decision logic and a Cancellable subscription handle. createPersonalizationSDK now registers each instance; deleteUserCredentials and a new deinit unregister it. Instances are held weakly, so a host-released SDK drops out of the registry on deallocation. No public API is added and single-instance behaviour is unchanged. Cover the registry and both resolvers with unit tests in REES46Tests (28 tests), including the weak-reference auto-drop. --- DemoApp/REES46.xcodeproj/project.pbxproj | 36 +++- DemoApp/Rees46DemoTests/FakeSDK.swift | 84 ++++++++ .../InstanceResolverTests.swift | 54 +++++ .../PushTargetResolverTests.swift | 41 ++++ .../Rees46DemoTests/SdkRegistryTests.swift | 178 ++++++++++++++++ REES46/Classes/Sdk/Registry/Cancellable.swift | 42 ++++ .../Sdk/Registry/InstanceResolver.swift | 58 ++++++ .../Sdk/Registry/PushTargetResolver.swift | 27 +++ REES46/Classes/Sdk/Registry/SdkRegistry.swift | 193 ++++++++++++++++++ .../Sdk/impl/SimplePersonalizationSDK.swift | 12 +- .../PersonalizationSDK.protocol.swift | 8 +- 11 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/FakeSDK.swift create mode 100644 DemoApp/Rees46DemoTests/InstanceResolverTests.swift create mode 100644 DemoApp/Rees46DemoTests/PushTargetResolverTests.swift create mode 100644 DemoApp/Rees46DemoTests/SdkRegistryTests.swift create mode 100644 REES46/Classes/Sdk/Registry/Cancellable.swift create mode 100644 REES46/Classes/Sdk/Registry/InstanceResolver.swift create mode 100644 REES46/Classes/Sdk/Registry/PushTargetResolver.swift create mode 100644 REES46/Classes/Sdk/Registry/SdkRegistry.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index 1545dea3..9e09cc21 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -8,22 +8,21 @@ /* Begin PBXBuildFile section */ 0169B35A9F893F6D8D22820A /* Pods_REES46Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */; }; + 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */; }; 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 607FACD81AFB9204008FA782 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* MainViewController.swift */; }; 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; + 67B36DC5275BCA31AB0A0363 /* SdkRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */; }; + 6805322F7DF2A2E2F1234B24 /* InstanceResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */; }; 7587149C2C6CDF62008E5E4E /* SDKConfigProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7587149B2C6CDF62008E5E4E /* SDKConfigProviderTests.swift */; }; 758714A82C6CF1EB008E5E4E /* AppEnvironments.struct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758714A62C6CF1EB008E5E4E /* AppEnvironments.struct.swift */; }; 75A4D60C2C5B8EAA00929368 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 75A4D60B2C5B8EAA00929368 /* GoogleService-Info.plist */; }; 75D1D5C12CA72BF200C8C564 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 75D1D5C02CA72BF200C8C564 /* Localizable.xcstrings */; }; 75E237902C334AA800119197 /* SdkTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E2378E2C334AA800119197 /* SdkTest.swift */; }; - A1B2C3D40000000000000001 /* GetLastOrderProductsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* GetLastOrderProductsTest.swift */; }; - A1B2C3D40000000000000003 /* GetUserOrdersTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000004 /* GetUserOrdersTest.swift */; }; - A1B2C3D40000000000000005 /* LoyaltyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000006 /* LoyaltyTests.swift */; }; - A1B2C3D40000000000000007 /* CatalogReadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000008 /* CatalogReadTests.swift */; }; 75E237932C334AD200119197 /* DeviceIdTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E237912C334AD200119197 /* DeviceIdTest.swift */; }; - D1C0FFEF2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C0FFEE2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift */; }; + 7FEFCE64D31526E14ADA2679 /* FakeSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */; }; 9A7A95DF25BC40E900FFA40A /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31DE25A1C6F700D9D1AA /* UserNotifications.framework */; }; 9A7A95E025BC40E900FFA40A /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31E025A1C6F700D9D1AA /* UserNotificationsUI.framework */; }; 9A7A95E325BC40E900FFA40A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7A95E225BC40E900FFA40A /* NotificationViewController.swift */; }; @@ -37,6 +36,10 @@ 9A7FC75625C0BB69006D8CF9 /* Readme.md in Resources */ = {isa = PBXBuildFile; fileRef = 9A7FC75525C0BB69006D8CF9 /* Readme.md */; }; 9AFB9EE128044E4A001462AC /* PushPresentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AFB9EDF28044E4A001462AC /* PushPresentViewController.swift */; }; 9AFB9EE228044E4A001462AC /* PushPresentViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9AFB9EE028044E4A001462AC /* PushPresentViewController.xib */; }; + A1B2C3D40000000000000001 /* GetLastOrderProductsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* GetLastOrderProductsTest.swift */; }; + A1B2C3D40000000000000003 /* GetUserOrdersTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000004 /* GetUserOrdersTest.swift */; }; + A1B2C3D40000000000000005 /* LoyaltyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000006 /* LoyaltyTests.swift */; }; + A1B2C3D40000000000000007 /* CatalogReadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000008 /* CatalogReadTests.swift */; }; D005D80D2DB2CDE900841D78 /* MockGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D005D80C2DB2CDE000841D78 /* MockGenerator.swift */; }; D0763E5A2DB8F51D0013E367 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0763E592DB8F5170013E367 /* Constants.swift */; }; D0B70C172D522DAF0001782A /* SearchServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B70C162D522DAF0001782A /* SearchServiceTests.swift */; }; @@ -45,6 +48,7 @@ D0B99FA32DB16C960012E4F0 /* SubscribeForPriceDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA22DB16C820012E4F0 /* SubscribeForPriceDrop.swift */; }; D0B99FA52DB16D290012E4F0 /* UnsubscribePriceDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA42DB16D1A0012E4F0 /* UnsubscribePriceDrop.swift */; }; D0B99FA72DB172940012E4F0 /* ManageSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA62DB1728D0012E4F0 /* ManageSubscription.swift */; }; + D1C0FFEF2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C0FFEE2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift */; }; D589BD74A3C4A24BF7BCCD2C /* Pods_REES46Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */; }; E9049B762B1F561100BD7FEB /* Inter-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B752B1F561100BD7FEB /* Inter-Bold.ttf */; }; E9049B782B1F56D700BD7FEB /* Inter-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B772B1F56D700BD7FEB /* Inter-Semibold.ttf */; }; @@ -105,18 +109,15 @@ 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 607FACE51AFB9204008FA782 /* REES46Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = REES46Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SdkRegistryTests.swift; sourceTree = ""; }; 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7587149B2C6CDF62008E5E4E /* SDKConfigProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKConfigProviderTests.swift; sourceTree = ""; }; 758714A62C6CF1EB008E5E4E /* AppEnvironments.struct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppEnvironments.struct.swift; sourceTree = ""; }; 75A4D60B2C5B8EAA00929368 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 75D1D5C02CA72BF200C8C564 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 75E2378E2C334AA800119197 /* SdkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SdkTest.swift; sourceTree = ""; }; - A1B2C3D40000000000000002 /* GetLastOrderProductsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetLastOrderProductsTest.swift; sourceTree = ""; }; - A1B2C3D40000000000000004 /* GetUserOrdersTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUserOrdersTest.swift; sourceTree = ""; }; - A1B2C3D40000000000000006 /* LoyaltyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoyaltyTests.swift; sourceTree = ""; }; - A1B2C3D40000000000000008 /* CatalogReadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogReadTests.swift; sourceTree = ""; }; 75E237912C334AD200119197 /* DeviceIdTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdTest.swift; sourceTree = ""; }; - D1C0FFEE2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackEventCustomFieldsTests.swift; sourceTree = ""; }; + 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushTargetResolverTests.swift; sourceTree = ""; }; 9A58AAB128E80C33004EA84C /* REESNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = REESNotificationServiceExtension.entitlements; sourceTree = ""; }; 9A58AAB228E80EEA004EA84C /* REESNotificationContentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = REESNotificationContentExtension.entitlements; sourceTree = ""; }; 9A7A95DE25BC40E900FFA40A /* REESNotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = REESNotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -136,7 +137,13 @@ 9AFB9EDF28044E4A001462AC /* PushPresentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPresentViewController.swift; sourceTree = ""; }; 9AFB9EE028044E4A001462AC /* PushPresentViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PushPresentViewController.xib; sourceTree = ""; }; 9E7FFE52FCE8111B44F38A5E /* Pods-REES46Demo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Demo.debug.xcconfig"; path = "Target Support Files/Pods-REES46Demo/Pods-REES46Demo.debug.xcconfig"; sourceTree = ""; }; + A1B2C3D40000000000000002 /* GetLastOrderProductsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetLastOrderProductsTest.swift; sourceTree = ""; }; + A1B2C3D40000000000000004 /* GetUserOrdersTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUserOrdersTest.swift; sourceTree = ""; }; + A1B2C3D40000000000000006 /* LoyaltyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoyaltyTests.swift; sourceTree = ""; }; + A1B2C3D40000000000000008 /* CatalogReadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogReadTests.swift; sourceTree = ""; }; AAB9149EDCCCB4FF18DB87D6 /* Pods-REES46Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Tests.release.xcconfig"; path = "Target Support Files/Pods-REES46Tests/Pods-REES46Tests.release.xcconfig"; sourceTree = ""; }; + AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FakeSDK.swift; sourceTree = ""; }; + B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InstanceResolverTests.swift; sourceTree = ""; }; B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D005D80C2DB2CDE000841D78 /* MockGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenerator.swift; sourceTree = ""; }; D0763E592DB8F5170013E367 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; @@ -146,6 +153,7 @@ D0B99FA22DB16C820012E4F0 /* SubscribeForPriceDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeForPriceDrop.swift; sourceTree = ""; }; D0B99FA42DB16D1A0012E4F0 /* UnsubscribePriceDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribePriceDrop.swift; sourceTree = ""; }; D0B99FA62DB1728D0012E4F0 /* ManageSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageSubscription.swift; sourceTree = ""; wrapsLines = 0; }; + D1C0FFEE2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackEventCustomFieldsTests.swift; sourceTree = ""; }; DF636BC1FCE3C2D61578639A /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; E9049B752B1F561100BD7FEB /* Inter-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-Bold.ttf"; sourceTree = ""; }; E9049B772B1F56D700BD7FEB /* Inter-Semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-Semibold.ttf"; sourceTree = ""; }; @@ -274,6 +282,10 @@ D0763E592DB8F5170013E367 /* Constants.swift */, 75E2378B2C334A4200119197 /* services */, 607FACE91AFB9204008FA782 /* Supporting Files */, + AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */, + B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */, + 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */, + 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -704,6 +716,10 @@ 7587149C2C6CDF62008E5E4E /* SDKConfigProviderTests.swift in Sources */, 75E237932C334AD200119197 /* DeviceIdTest.swift in Sources */, D005D80D2DB2CDE900841D78 /* MockGenerator.swift in Sources */, + 7FEFCE64D31526E14ADA2679 /* FakeSDK.swift in Sources */, + 6805322F7DF2A2E2F1234B24 /* InstanceResolverTests.swift in Sources */, + 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */, + 67B36DC5275BCA31AB0A0363 /* SdkRegistryTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/FakeSDK.swift b/DemoApp/Rees46DemoTests/FakeSDK.swift new file mode 100644 index 00000000..94f03aae --- /dev/null +++ b/DemoApp/Rees46DemoTests/FakeSDK.swift @@ -0,0 +1,84 @@ +// +// FakeSDK.swift +// REES46Tests +// +// A minimal `PersonalizationSDK` conformer for the multi-instance registry tests. +// +// The registry never calls into an instance — it only stores and resolves references by identity — +// so every method here is a no-op and every getter returns a stored/placeholder value. This is the +// iOS analog of the Android `SdkRegistryTest` using bare `SDK()` instances, but offline and with no +// background retention (a real `SimplePersonalizationSDK` would spawn an init request that keeps the +// instance alive, defeating the weak-reference / dealloc tests). +// + +import Foundation +import UIKit +@testable import REES46 + +final class FakeSDK: PersonalizationSDK { + + var shopId: String + var deviceId: String = "fake-device-id" + var userSeance: String = "fake-seance" + var segment: String = "A" + var storiesCode: String? + let sessionQueue: SessionQueue = SessionQueue.manager + var parentViewController: UIViewController? + var urlSession: URLSession = .shared + var popupPresentationDelegate: PopupPresentationDelegate? + var enableAutoPopupPresentation: Bool = true + lazy var popupPresenter: PopupPresenter = PopupPresenter(sdk: self) + + init(shopId: String = "fake-shop") { + self.shopId = shopId + } + + func postRequest(path: String, params: [String: Any], completion: @escaping (Result<[String: Any], SdkError>) -> Void) {} + func getRequest(path: String, params: [String: String], _ isInit: Bool, completion: @escaping (Result<[String: Any], SdkError>) -> Void) {} + func configureURLSession(configuration: URLSessionConfiguration) {} + func generateString(array: [String]) -> String { "" } + func setProfileData(userEmail: String?, userPhone: String?, userLoyaltyId: String?, birthday: Date?, age: Int?, firstName: String?, lastName: String?, location: String?, gender: Gender?, advertisingId: String?, fbID: String?, vkID: String?, telegramId: String?, loyaltyCardLocation: String?, loyaltyStatus: String?, loyaltyBonuses: Int?, loyaltyBonusesToNextLevel: Int?, boughtSomething: Bool?, userId: String?, customProperties: [String: Any?]?, completion: @escaping (Result) -> Void) {} + func track(event: Event, recommendedBy: RecomendedBy?, completion: @escaping (Result) -> Void) {} + func trackPurchase(_ request: PurchaseTrackingRequest, recommendedBy: RecomendedBy?, completion: @escaping (Result) -> Void) {} + func trackSource(source: RecommendedByCase, code: String) {} + func trackEvent(event: String, time: Int?, category: String?, label: String?, value: Int?, customFields: [String: Any]?, completion: @escaping (Result) -> Void) {} + func trackPopupShown(popupId: Int, completion: @escaping (Result) -> Void) {} + func recommend(blockId: String, currentProductId: String?, currentCategoryId: String?, locations: String?, imageSize: String?, timeOut: Double?, withLocations: Bool, extended: Bool, completion: @escaping (Result) -> Void) {} + func suggest(query: String, locations: String?, excludedMerchants: [String]?, excludedBrands: [String]?, timeOut: Double?, extended: String?, completion: @escaping (Result) -> Void) {} + func getProductsList(brands: String?, merchants: String?, categories: String?, locations: String?, limit: Int?, page: Int?, filters: [String: Any]?, completion: @escaping (Result) -> Void) {} + func getProductsFromCart(completion: @escaping (Result<[CartItem], SdkError>) -> Void) {} + func getProductInfo(id: String, completion: @escaping (Result) -> Void) {} + func getLastOrderProducts(completion: @escaping (Result) -> Void) {} + func getUserOrders(shopSecret: String, did: String?, email: String?, phone: String?, loyaltyId: String?, externalId: String?, dateFrom: String?, completion: @escaping (Result<[Order], SdkError>) -> Void) {} + func joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, completion: @escaping (Result) -> Void) {} + func getLoyaltyStatus(identifier: String, completion: @escaping (Result) -> Void) {} + func getProfile(completion: @escaping (Result) -> Void) {} + func getProductCounters(item: String, completion: @escaping (Result) -> Void) {} + func getCategory(category: String, limit: Int?, page: Int?, brands: String?, locations: String?, filters: [String: Any]?, completion: @escaping (Result) -> Void) {} + func getCollection(collectionId: String, location: String?, email: String?, phone: String?, externalId: String?, loyaltyId: String?, completion: @escaping (Result) -> Void) {} + func getDeviceId() -> String { deviceId } + func setParentViewController(controller: UIViewController, completion: @escaping () -> Void) {} + func getNotificationWidget() -> NotificationWidget? { nil } + func getSession() -> String { userSeance } + func getCurrentSegment() -> String { segment } + func getShopId() -> String { shopId } + func setPushTokenNotification(token: String, isFirebaseNotification: Bool, completion: @escaping (Result) -> Void) {} + func review(rate: Int, channel: String, category: String, orderId: String?, comment: String?, completion: @escaping (Result) -> Void) {} + func searchBlank(completion: @escaping (Result) -> Void) {} + func search(query: String, limit: Int?, offset: Int?, categoryLimit: Int?, brandLimit: Int?, categories: [Int]?, extended: String?, sortBy: String?, sortDir: String?, locations: String?, excludedMerchants: [String]?, excludedBrands: [String]?, brands: String?, filters: [String: Any]?, priceMin: Double?, priceMax: Double?, colors: [String]?, fashionSizes: [String]?, exclude: String?, email: String?, timeOut: Double?, disableClarification: Bool?, completion: @escaping (Result) -> Void) {} + func notificationClicked(type: String, code: String, completion: @escaping (Result) -> Void) {} + func notificationDelivered(type: String, code: String, completion: @escaping (Result) -> Void) {} + func notificationReceived(type: String, code: String, completion: @escaping (Result) -> Void) {} + func subscribeForBackInStock(id: String, email: String?, phone: String?, fashionSize: String?, fashionColor: String?, barcode: String?, completion: @escaping (Result) -> Void) {} + func unsubscribeForBackInStock(itemIds: [String], email: String?, phone: String?, completion: @escaping (Result) -> Void) {} + func subscribeForPriceDrop(id: String, currentPrice: Double, email: String?, phone: String?, completion: @escaping (Result) -> Void) {} + func unsubscribeForPriceDrop(itemIds: [String], currentPrice: Double, email: String?, phone: String?, completion: @escaping (Result) -> Void) {} + func getStories(code: String, completion: @escaping (Result) -> Void) {} + func getProbabilityToPurchase(params: PurchasePredictParams, completion: @escaping (Result) -> Void) {} + func addToSegment(segmentId: String, email: String?, phone: String?, completion: @escaping (Result) -> Void) {} + func removeFromSegment(segmentId: String, email: String?, phone: String?, completion: @escaping (Result) -> Void) {} + func manageSubscription(email: String?, phone: String?, userExternalId: String?, userLoyaltyId: String?, telegramId: String?, emailBulk: Bool?, emailChain: Bool?, emailTransactional: Bool?, smsBulk: Bool?, smsChain: Bool?, smsTransactional: Bool?, webPushBulk: Bool?, webPushChain: Bool?, webPushTransactional: Bool?, mobilePushBulk: Bool?, mobilePushChain: Bool?, mobilePushTransactional: Bool?, completion: @escaping (Result) -> Void) {} + func configuration() -> SdkConfiguration.Type { SdkConfiguration.self } + func sendIDFARequest(idfa: UUID, completion: @escaping (Result) -> Void) {} + func deleteUserCredentials() {} +} diff --git a/DemoApp/Rees46DemoTests/InstanceResolverTests.swift b/DemoApp/Rees46DemoTests/InstanceResolverTests.swift new file mode 100644 index 00000000..64cd9440 --- /dev/null +++ b/DemoApp/Rees46DemoTests/InstanceResolverTests.swift @@ -0,0 +1,54 @@ +// +// InstanceResolverTests.swift +// REES46Tests +// +// Covers the resolution rules behind the future `Rees46.instance(for:)` (R3). Pure logic — no SDK +// construction — so every branch of the single/ambiguous/uninitialized contract is pinned here. +// Mirror of the Android `InstanceResolverTest`. +// + +import XCTest +@testable import REES46 + +final class InstanceResolverTests: XCTestCase { + + private func resolve(_ requested: String?, live: Set, pending: Set) -> InstanceResolution { + InstanceResolver.resolve(requestedShopId: requested, liveShopIds: live, pendingShopIds: pending) + } + + func test_requested_shop_with_a_live_instance_resolves_to_existing() { + XCTAssertEqual(resolve("shop-a", live: ["shop-a"], pending: []), .existing("shop-a")) + } + + func test_requested_shop_that_is_only_registered_resolves_to_pending() { + XCTAssertEqual(resolve("shop-a", live: [], pending: ["shop-a"]), .pending("shop-a")) + } + + func test_a_live_registration_wins_over_a_stale_pending_one_for_the_same_shop() { + XCTAssertEqual(resolve("shop-a", live: ["shop-a"], pending: ["shop-a"]), .existing("shop-a")) + } + + func test_requested_shop_that_is_unknown_resolves_to_notRegistered() { + XCTAssertEqual(resolve("shop-x", live: ["shop-a"], pending: ["shop-b"]), .notRegistered) + } + + func test_no_shopId_and_nothing_registered_resolves_to_notRegistered() { + XCTAssertEqual(resolve(nil, live: [], pending: []), .notRegistered) + } + + func test_no_shopId_and_exactly_one_live_shop_resolves_to_that_existing() { + XCTAssertEqual(resolve(nil, live: ["shop-a"], pending: []), .existing("shop-a")) + } + + func test_no_shopId_and_exactly_one_pending_shop_resolves_to_that_pending() { + XCTAssertEqual(resolve(nil, live: [], pending: ["shop-a"]), .pending("shop-a")) + } + + func test_no_shopId_and_more_than_one_shop_resolves_to_ambiguous() { + XCTAssertEqual(resolve(nil, live: ["shop-a"], pending: ["shop-b"]), .ambiguous) + } + + func test_no_shopId_and_two_live_shops_resolves_to_ambiguous() { + XCTAssertEqual(resolve(nil, live: ["shop-a", "shop-b"], pending: []), .ambiguous) + } +} diff --git a/DemoApp/Rees46DemoTests/PushTargetResolverTests.swift b/DemoApp/Rees46DemoTests/PushTargetResolverTests.swift new file mode 100644 index 00000000..e7901852 --- /dev/null +++ b/DemoApp/Rees46DemoTests/PushTargetResolverTests.swift @@ -0,0 +1,41 @@ +// +// PushTargetResolverTests.swift +// REES46Tests +// +// Covers push routing (R4): which shop a push belongs to. Pure logic — mirror of the routing rules +// pinned on Android in `PushRoutingTest`. +// + +import XCTest +@testable import REES46 + +final class PushTargetResolverTests: XCTestCase { + + private func resolve(_ payloadShopId: String?, live: Set) -> String? { + PushTargetResolver.resolve(payloadShopId: payloadShopId, liveShopIds: live) + } + + func test_payload_shopId_matching_a_live_shop_routes_to_it() { + XCTAssertEqual(resolve("shop-b", live: ["shop-a", "shop-b"]), "shop-b") + } + + func test_payload_shopId_that_names_no_live_shop_is_dropped() { + XCTAssertNil(resolve("shop-x", live: ["shop-a", "shop-b"])) + } + + func test_no_shopId_with_a_single_live_shop_falls_back_to_it() { + XCTAssertEqual(resolve(nil, live: ["shop-a"]), "shop-a") + } + + func test_no_shopId_with_several_live_shops_is_dropped() { + XCTAssertNil(resolve(nil, live: ["shop-a", "shop-b"])) + } + + func test_no_shopId_with_no_live_shops_is_dropped() { + XCTAssertNil(resolve(nil, live: [])) + } + + func test_payload_shopId_matching_the_only_live_shop_routes_to_it() { + XCTAssertEqual(resolve("shop-a", live: ["shop-a"]), "shop-a") + } +} diff --git a/DemoApp/Rees46DemoTests/SdkRegistryTests.swift b/DemoApp/Rees46DemoTests/SdkRegistryTests.swift new file mode 100644 index 00000000..072cbfa1 --- /dev/null +++ b/DemoApp/Rees46DemoTests/SdkRegistryTests.swift @@ -0,0 +1,178 @@ +// +// SdkRegistryTests.swift +// REES46Tests +// +// Covers `SdkRegistry`, the internal routing state behind multi-instance (R1). Pins the +// single-instance behaviour (unchanged) and the shop-id resolution the public API will build on. +// Mirror of the Android `SdkRegistryTest`, adapted for the iOS design difference: the registry +// holds instances **weakly** (the host owns their lifetime), so tests bind each `FakeSDK` to a local +// to keep it alive, and there is an extra test for the auto-drop on deallocation. +// +// `PersonalizationSDK` is not class-constrained, so identity is compared through `same(_:_:)`, which +// bridges to `AnyObject` — the same bridge the registry uses internally to key on reference identity. +// + +import XCTest +@testable import REES46 + +final class SdkRegistryTests: XCTestCase { + + private var registry: SdkRegistry { SdkRegistry.shared } + + private func same(_ lhs: PersonalizationSDK?, _ rhs: PersonalizationSDK?) -> Bool { + (lhs as AnyObject?) === (rhs as AnyObject?) + } + + override func setUp() { + super.setUp() + registry.reset() + } + + override func tearDown() { + registry.reset() + super.tearDown() + } + + func test_a_registered_instance_becomes_current_and_is_resolvable_by_shop_id() { + let sdk = FakeSDK(shopId: "shop-a") + + registry.register(shopId: "shop-a", sdk: sdk) + + XCTAssertTrue(same(registry.current(), sdk)) + XCTAssertTrue(same(registry.byShopId("shop-a"), sdk)) + XCTAssertEqual(registry.count(), 1) + } + + func test_the_last_registered_instance_is_the_current_default() { + let first = FakeSDK(shopId: "shop-a") + let second = FakeSDK(shopId: "shop-b") + + registry.register(shopId: "shop-a", sdk: first) + registry.register(shopId: "shop-b", sdk: second) + + XCTAssertTrue(same(registry.current(), second)) + XCTAssertEqual(registry.count(), 2) + } + + func test_each_shop_id_resolves_to_its_own_instance() { + let a = FakeSDK(shopId: "shop-a") + let b = FakeSDK(shopId: "shop-b") + registry.register(shopId: "shop-a", sdk: a) + registry.register(shopId: "shop-b", sdk: b) + + XCTAssertTrue(same(registry.byShopId("shop-a"), a)) + XCTAssertTrue(same(registry.byShopId("shop-b"), b)) + } + + func test_an_unknown_shop_id_resolves_to_nil() { + let a = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: a) + + XCTAssertNil(registry.byShopId("shop-x")) + } + + func test_re_registering_the_same_instance_keeps_a_single_entry() { + let sdk = FakeSDK(shopId: "shop-a") + + registry.register(shopId: "shop-a", sdk: sdk) + registry.register(shopId: "shop-a", sdk: sdk) + + XCTAssertEqual(registry.count(), 1) + } + + func test_re_registering_a_shop_evicts_the_superseded_instance_from_the_fan_out_set() { + let old = FakeSDK(shopId: "shop-a") + let new = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: old) + registry.register(shopId: "shop-a", sdk: new) + + XCTAssertEqual(registry.count(), 1) + XCTAssertTrue(same(registry.byShopId("shop-a"), new)) + let all = registry.all() + XCTAssertEqual(all.count, 1) + XCTAssertTrue(same(all.first, new)) + } + + func test_onNextRegister_fires_immediately_when_the_shop_is_already_registered() { + let sdk = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: sdk) + + var received: PersonalizationSDK? + registry.onNextRegister(shopId: "shop-a") { received = $0 } + + XCTAssertTrue(same(received, sdk)) + } + + func test_onNextRegister_without_a_shop_fires_immediately_when_an_instance_is_current() { + let sdk = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: sdk) + + var received: PersonalizationSDK? + registry.onNextRegister(shopId: nil) { received = $0 } + + XCTAssertTrue(same(received, sdk)) + } + + func test_onNextRegister_waits_and_then_fires_on_the_next_matching_registration() { + var received: PersonalizationSDK? + registry.onNextRegister(shopId: "shop-a") { received = $0 } + XCTAssertNil(received) + + let sdk = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: sdk) + + XCTAssertTrue(same(received, sdk)) + } + + func test_a_cancelled_onNextRegister_does_not_fire_on_a_later_registration() { + var received: PersonalizationSDK? + let handle = registry.onNextRegister(shopId: "shop-a") { received = $0 } + handle.cancel() + + let sdk = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: sdk) + + XCTAssertNil(received) + } + + func test_all_returns_every_registered_instance_for_push_fan_out() { + let a = FakeSDK(shopId: "shop-a") + let b = FakeSDK(shopId: "shop-b") + registry.register(shopId: "shop-a", sdk: a) + registry.register(shopId: "shop-b", sdk: b) + + let all = registry.all() + + XCTAssertEqual(all.count, 2) + XCTAssertTrue(all.contains { same($0, a) }) + XCTAssertTrue(all.contains { same($0, b) }) + } + + func test_unregister_drops_the_instance_from_the_fan_out_set_and_the_shop_mapping() { + let a = FakeSDK(shopId: "shop-a") + let b = FakeSDK(shopId: "shop-b") + registry.register(shopId: "shop-a", sdk: a) + registry.register(shopId: "shop-b", sdk: b) + + registry.unregister(a) + + XCTAssertEqual(registry.count(), 1) + XCTAssertNil(registry.byShopId("shop-a")) + XCTAssertTrue(same(registry.byShopId("shop-b"), b)) + } + + /// iOS-specific: the registry references instances weakly, so an instance the host stops + /// retaining deallocs and drops out on its own — no explicit unregister required. + func test_a_deallocated_instance_drops_out_of_the_registry() { + do { + let sdk = FakeSDK(shopId: "shop-a") + registry.register(shopId: "shop-a", sdk: sdk) + XCTAssertEqual(registry.count(), 1) + } + + XCTAssertNil(registry.byShopId("shop-a")) + XCTAssertNil(registry.current()) + XCTAssertEqual(registry.count(), 0) + XCTAssertTrue(registry.all().isEmpty) + } +} diff --git a/REES46/Classes/Sdk/Registry/Cancellable.swift b/REES46/Classes/Sdk/Registry/Cancellable.swift new file mode 100644 index 00000000..99d380e4 --- /dev/null +++ b/REES46/Classes/Sdk/Registry/Cancellable.swift @@ -0,0 +1,42 @@ +// +// Cancellable.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Handle for a subscription that must be torn down — e.g. a `SdkRegistry.onNextRegister` callback + cancelled when the view that registered it detaches, so the callback (and whatever it captures) is + not leaked. + + Mirror of the Android `Cancellable` fun-interface. Kept `internal` in R1 (the registry is the only + caller); the public `Rees46.awaitInstance` facade in R3 will re-expose it. `cancel()` is idempotent + — calling it after the subscription already fired or was cancelled is a no-op. + */ +internal final class Cancellable { + + /// A no-op handle for subscriptions that resolved synchronously and hold nothing. + static let noop = Cancellable {} + + private let onCancel: () -> Void + private let lock = NSLock() + private var cancelled = false + + init(_ onCancel: @escaping () -> Void) { + self.onCancel = onCancel + } + + func cancel() { + lock.lock() + let alreadyCancelled = cancelled + cancelled = true + lock.unlock() + if !alreadyCancelled { + onCancel() + } + } +} diff --git a/REES46/Classes/Sdk/Registry/InstanceResolver.swift b/REES46/Classes/Sdk/Registry/InstanceResolver.swift new file mode 100644 index 00000000..2ad3cb55 --- /dev/null +++ b/REES46/Classes/Sdk/Registry/InstanceResolver.swift @@ -0,0 +1,58 @@ +// +// InstanceResolver.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + The outcome of resolving a `shopId` (or none) against the live and pending shops. + + Kept a plain value type so the resolution rules can be tested without constructing or initializing + an SDK. Conforms to `Equatable` for exhaustive table tests. + */ +internal enum InstanceResolution: Equatable { + /// An initialized instance exists for this shop — return it. + case existing(String) + /// A registration exists but is not initialized yet — materialize it now. + case pending(String) + /// No live instance and no registration matches — raise `unknownShopId`. + case notRegistered + /// No `shopId` given and more than one shop registered — raise `ambiguousShop`. + case ambiguous +} + +/** + Pure decision logic behind the future `Rees46.instance(for:)` (R3): given the requested `shopId` + (or none) and the sets of live and pending shops, decides which instance to return, whether one must + be lazily materialized, or which error to raise. Side-effect-free — mirror of the Android + `InstanceResolver`. + */ +internal enum InstanceResolver { + + static func resolve( + requestedShopId: String?, + liveShopIds: Set, + pendingShopIds: Set + ) -> InstanceResolution { + if let requested = requestedShopId { + if liveShopIds.contains(requested) { return .existing(requested) } + if pendingShopIds.contains(requested) { return .pending(requested) } + return .notRegistered + } + + let allShopIds = liveShopIds.union(pendingShopIds) + switch allShopIds.count { + case 0: + return .notRegistered + case 1: + let only = allShopIds.first! + return liveShopIds.contains(only) ? .existing(only) : .pending(only) + default: + return .ambiguous + } + } +} diff --git a/REES46/Classes/Sdk/Registry/PushTargetResolver.swift b/REES46/Classes/Sdk/Registry/PushTargetResolver.swift new file mode 100644 index 00000000..4f74ba31 --- /dev/null +++ b/REES46/Classes/Sdk/Registry/PushTargetResolver.swift @@ -0,0 +1,27 @@ +// +// PushTargetResolver.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Pure decision behind push routing (R4): which shop a push belongs to. + + The payload's `shop_id` names the target; with no `shop_id`, a single-instance app still works (the + one live shop is used). A `shop_id` that names no live instance, or an absent `shop_id` while several + shops are live, resolves to `nil` — the push is dropped rather than delivered to the wrong shop. + Side-effect-free — mirror of the Android `PushTargetResolver`. + */ +internal enum PushTargetResolver { + + static func resolve(payloadShopId: String?, liveShopIds: Set) -> String? { + if let payloadShopId = payloadShopId { + return liveShopIds.contains(payloadShopId) ? payloadShopId : nil + } + return liveShopIds.count == 1 ? liveShopIds.first : nil + } +} diff --git a/REES46/Classes/Sdk/Registry/SdkRegistry.swift b/REES46/Classes/Sdk/Registry/SdkRegistry.swift new file mode 100644 index 00000000..6e850170 --- /dev/null +++ b/REES46/Classes/Sdk/Registry/SdkRegistry.swift @@ -0,0 +1,193 @@ +// +// SdkRegistry.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Process-wide registry of `PersonalizationSDK` instances. + + Groundwork for multi-instance (one SDK per `shop_id`). In R1 it is `internal` and carries no public + surface: it owns the routing state — an ordered fan-out set of live instances plus a "current" + default pointer — so the single-instance behaviour is unchanged while the plumbing for resolving an + instance by `shop_id` is in place and tested. The public `Rees46.initialize/instance(for:)/register` + API is layered on top later (R3), and push routing (R4) consumes `PushTargetResolver` + `all()`. + + Mirror of the Android `SdkRegistry`, with one deliberate iOS difference: **references are weak.** On + Android the SDK is a long-lived process singleton held by the JVM; on iOS the host owns the SDK's + lifetime (e.g. `AppDelegate` retains it), so the registry must not keep instances alive — otherwise + they leak and `deinit`-driven unregister (iOS-4) could never fire. An instance the host stops + retaining deallocs and drops out of the registry automatically; the projections below skip any weak + slot whose object is already gone. + + Thread-safe: state is read from the messaging threads and written from whatever thread called + `createPersonalizationSDK`. All mutations run as barriers on a concurrent queue, so a registration + and a concurrent `onNextRegister` cannot interleave and lose a notification. Callback invocation is + deliberately kept outside the barrier — a host's `onReady` may run arbitrary code and re-enter the + registry. + */ +internal final class SdkRegistry { + + static let shared = SdkRegistry() + + private init() {} + + /// Weakly holds one instance so a host-released SDK deallocs and leaves the registry on its own. + private final class WeakBox { + weak var value: AnyObject? + init(_ sdk: PersonalizationSDK) { value = sdk as AnyObject } + var sdk: PersonalizationSDK? { value as? PersonalizationSDK } + } + + private final class Awaiter { + let shopId: String? + let onReady: (PersonalizationSDK) -> Void + init(shopId: String?, onReady: @escaping (PersonalizationSDK) -> Void) { + self.shopId = shopId + self.onReady = onReady + } + } + + private var instances: [WeakBox] = [] // ordered fan-out set (push-token targets) + private var byShop: [String: WeakBox] = [:] // shopId -> live instance + private var awaiting: [Awaiter] = [] + private weak var currentDefault: AnyObject? // the current default (last registered) + + /// Serialises the compound routing-state updates; reads run concurrently, writes as barriers. + private let queue = DispatchQueue(label: "com.rees46.sdkRegistry", attributes: .concurrent) + + // MARK: - Mutations + + /** + Records an initialized `sdk` for `shopId`: it joins the push fan-out set and becomes the current + default. Re-registering the same instance keeps a single entry; a new instance for an + already-known `shopId` replaces the mapping (last writer wins) and evicts the superseded instance + from the fan-out set, so a re-init does not leave the old object receiving push tokens. + */ + func register(shopId: String, sdk: PersonalizationSDK) { + var toNotify: [Awaiter] = [] + let object = sdk as AnyObject + queue.sync(flags: .barrier) { + compactLocked() + // Dedup by identity, then append so the newest registration is last in the fan-out order. + instances.removeAll { $0.value === object } + instances.append(WeakBox(sdk)) + // A re-init builds a new SDK for the same shop: drop the one it supersedes, or it lingers + // in the fan-out set forever — still fed push tokens and inflating all()/count(). + if let previous = byShop[shopId]?.value, previous !== object { + instances.removeAll { $0.value === previous } + } + byShop[shopId] = WeakBox(sdk) + currentDefault = object + toNotify = takeMatchingAwaitersLocked(shopId: shopId) + } + toNotify.forEach { $0.onReady(sdk) } + } + + /** + Subscribes to the next `register` matching `shopId` (`nil` matches the first registration of any + shop). Re-checks the live state under the barrier first: if the instance already arrived it fires + `onReady` immediately instead of waiting for a signal that has already passed. Returns a handle + that removes the subscription; call it when the waiter goes away (e.g. a view detaches) so the + callback is not leaked. + */ + @discardableResult + func onNextRegister(shopId: String?, onReady: @escaping (PersonalizationSDK) -> Void) -> Cancellable { + var alreadyLive: PersonalizationSDK? + var added: Awaiter? + queue.sync(flags: .barrier) { + compactLocked() + alreadyLive = (shopId != nil) ? byShop[shopId!]?.sdk : (currentDefault as? PersonalizationSDK) + if alreadyLive == nil { + let awaiter = Awaiter(shopId: shopId, onReady: onReady) + awaiting.append(awaiter) + added = awaiter + } + } + + if let live = alreadyLive { + onReady(live) + return Cancellable.noop + } + + let awaiter = added! + return Cancellable { [weak self] in + self?.queue.sync(flags: .barrier) { + self?.awaiting.removeAll { $0 === awaiter } + } + } + } + + /** + Drops `sdk` from the fan-out set and the shop mapping. `current` is intentionally left as-is, + matching the legacy release semantics (it only removed the instance from the fan-out set); + multi-instance revisits how the default is chosen after a release. Belt-and-suspenders for the + `deinit` path: a deallocated instance nils its weak slots anyway, this purges them eagerly. + */ + func unregister(_ sdk: PersonalizationSDK) { + let object = sdk as AnyObject + queue.sync(flags: .barrier) { + instances.removeAll { $0.value == nil || $0.value === object } + byShop = byShop.filter { $0.value.value != nil && $0.value.value !== object } + } + } + + /// Clears all state. Test-only: the registry is a process singleton. + func reset() { + queue.sync(flags: .barrier) { + instances.removeAll() + byShop.removeAll() + awaiting.removeAll() + currentDefault = nil + } + } + + // MARK: - Reads (skip weak slots whose object is gone; compaction happens on writes) + + /// Snapshot of every live instance — the push-token fan-out set, in registration order. + func all() -> [PersonalizationSDK] { + queue.sync { instances.compactMap { $0.sdk } } + } + + /// The current default instance, or `nil` when nothing has been registered (or it deallocated). + func current() -> PersonalizationSDK? { + queue.sync { currentDefault as? PersonalizationSDK } + } + + /// Resolves the live instance registered for `shopId`, or `nil` if none — the multi-instance hook. + func byShopId(_ shopId: String) -> PersonalizationSDK? { + queue.sync { byShop[shopId]?.sdk } + } + + /// Shop ids with a live, initialized instance. + func shopIds() -> Set { + queue.sync { Set(byShop.compactMap { $0.value.value != nil ? $0.key : nil }) } + } + + /// Number of live instances currently registered. + func count() -> Int { + queue.sync { instances.reduce(0) { $0 + ($1.value != nil ? 1 : 0) } } + } + + // MARK: - Locked helpers (must run inside the barrier) + + /// Removes weak slots whose object has deallocated. Mutating — barrier-only. + private func compactLocked() { + instances.removeAll { $0.value == nil } + byShop = byShop.filter { $0.value.value != nil } + } + + /// Removes and returns the awaiters matching `shopId` (`nil`-shop awaiters match any). Barrier-only. + private func takeMatchingAwaitersLocked(shopId: String) -> [Awaiter] { + guard !awaiting.isEmpty else { return [] } + let matched = awaiting.filter { $0.shopId == nil || $0.shopId == shopId } + if !matched.isEmpty { + awaiting.removeAll { candidate in matched.contains { $0 === candidate } } + } + return matched + } +} diff --git a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift index be0644ca..604fc6c5 100644 --- a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift +++ b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift @@ -1321,9 +1321,19 @@ class SimplePersonalizationSDK: PersonalizationSDK { let convertedInitJsonFileName = self.shopId + baseInitJsonFileName let initFileNamePath = SdkGlobalHelper.sharedInstance.getSdkDocumentsDirectory().appendingPathComponent(convertedInitJsonFileName) try? FileManager.default.removeItem(at: initFileNamePath) - + UserDefaults.standard.set(nil, forKey: SdkConstants.deviceIdKey) UserDefaults.standard.set(nil, forKey: "seance_id") + + // Multi-instance groundwork (R1): this instance is torn down — drop it from push routing so a + // superseded object is not fed push tokens or resolved by `shop_id`. + SdkRegistry.shared.unregister(self) + } + + deinit { + // Weak registry (see SdkRegistry): a host-released instance deallocs here — purge its routing + // slots eagerly instead of waiting for the next compaction. + SdkRegistry.shared.unregister(self) } func getStories(code: String, completion: @escaping (Result) -> Void) { diff --git a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift index 314805ee..6d72aa07 100644 --- a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift +++ b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift @@ -405,6 +405,12 @@ public func createPersonalizationSDK( ) sdk.resetSdkCache() - + + // Multi-instance groundwork (R1): record the instance so it can be resolved by `shop_id` and + // joins the push fan-out set. Single-instance behaviour is unchanged — nothing reads the registry + // yet through the public API. The registry holds the instance weakly, so this does not extend its + // lifetime; it drops out on dealloc or `deleteUserCredentials`. + SdkRegistry.shared.register(shopId: shopId, sdk: sdk) + return sdk } From 1ed3edef4281ff6e9371186f81685ce483efbc41 Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 16:25:37 +0400 Subject: [PATCH 02/10] feat(sdk): partition user identity storage per shop_id behind a repository Route device_id / seance / IDFA reads and writes through a new UserIdentityRepository port (UserDefaults-backed) instead of poking UserDefaults.standard directly across SimplePersonalizationSDK. Each shop gets its own UserDefaults suite (personalization_sdk_), so two shops in one app no longer share identity. The push token stays device-global. A one-time migration adopts the pre-partition identity from the shared domain into the first shop's partition (consume-once), so an existing install keeps its did with no re-registration; a host that supplies a custom storage key is used verbatim with no migration. Cover partition naming, the migration guard, the repository, and multi-instance isolation with unit tests. --- DemoApp/REES46.xcodeproj/project.pbxproj | 16 +++ .../StorageMigrationTests.swift | 91 ++++++++++++++ .../StoragePartitionTests.swift | 63 ++++++++++ ...UserIdentityRepositoryIsolationTests.swift | 113 ++++++++++++++++++ .../UserIdentityRepositoryTests.swift | 105 ++++++++++++++++ .../Sdk/Storage/StorageMigration.swift | 60 ++++++++++ .../Sdk/Storage/StoragePartition.swift | 65 ++++++++++ .../Sdk/Storage/UserIdentityRepository.swift | 40 +++++++ .../Storage/UserIdentityRepositoryImpl.swift | 63 ++++++++++ .../Sdk/impl/SimplePersonalizationSDK.swift | 46 ++++--- 10 files changed, 645 insertions(+), 17 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/StorageMigrationTests.swift create mode 100644 DemoApp/Rees46DemoTests/StoragePartitionTests.swift create mode 100644 DemoApp/Rees46DemoTests/UserIdentityRepositoryIsolationTests.swift create mode 100644 DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift create mode 100644 REES46/Classes/Sdk/Storage/StorageMigration.swift create mode 100644 REES46/Classes/Sdk/Storage/StoragePartition.swift create mode 100644 REES46/Classes/Sdk/Storage/UserIdentityRepository.swift create mode 100644 REES46/Classes/Sdk/Storage/UserIdentityRepositoryImpl.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index 9e09cc21..a223c2c0 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 75E237902C334AA800119197 /* SdkTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E2378E2C334AA800119197 /* SdkTest.swift */; }; 75E237932C334AD200119197 /* DeviceIdTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E237912C334AD200119197 /* DeviceIdTest.swift */; }; 7FEFCE64D31526E14ADA2679 /* FakeSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */; }; + 8A5B5B19354E21262D482DEB /* UserIdentityRepositoryIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */; }; 9A7A95DF25BC40E900FFA40A /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31DE25A1C6F700D9D1AA /* UserNotifications.framework */; }; 9A7A95E025BC40E900FFA40A /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31E025A1C6F700D9D1AA /* UserNotificationsUI.framework */; }; 9A7A95E325BC40E900FFA40A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7A95E225BC40E900FFA40A /* NotificationViewController.swift */; }; @@ -50,10 +51,13 @@ D0B99FA72DB172940012E4F0 /* ManageSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA62DB1728D0012E4F0 /* ManageSubscription.swift */; }; D1C0FFEF2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C0FFEE2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift */; }; D589BD74A3C4A24BF7BCCD2C /* Pods_REES46Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */; }; + DFDB6BC32790E30AA243B4E9 /* StoragePartitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */; }; E9049B762B1F561100BD7FEB /* Inter-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B752B1F561100BD7FEB /* Inter-Bold.ttf */; }; E9049B782B1F56D700BD7FEB /* Inter-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B772B1F56D700BD7FEB /* Inter-Semibold.ttf */; }; E9CF44412B30809C000F53F1 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CF44402B30809C000F53F1 /* SearchViewController.swift */; }; E9EAB8672AF08A43003DC1B5 /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9EAB8662AF08A43003DC1B5 /* Inter.ttf */; }; + F0605CC99B325F3301237A28 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */; }; + F808067588CEF0AA10BCA7D7 /* UserIdentityRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -98,6 +102,7 @@ /* Begin PBXFileReference section */ 1C8B363210138CD2A84F29F3 /* Pods-REES46Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Tests.debug.xcconfig"; path = "Target Support Files/Pods-REES46Tests/Pods-REES46Tests.debug.xcconfig"; sourceTree = ""; }; 35748965E9FE2297E49752C5 /* Pods-REES46Demo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Demo.release.xcconfig"; path = "Target Support Files/Pods-REES46Demo/Pods-REES46Demo.release.xcconfig"; sourceTree = ""; }; + 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoragePartitionTests.swift; sourceTree = ""; }; 53578CC38C4B3A4AA9973CCC /* REES46.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = REES46.podspec; path = ../REES46.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 5AE5CCEEB00A5EF1E5E39AFF /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 607FACD01AFB9204008FA782 /* REES46Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = REES46Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -111,6 +116,7 @@ 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SdkRegistryTests.swift; sourceTree = ""; }; 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 7587149B2C6CDF62008E5E4E /* SDKConfigProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKConfigProviderTests.swift; sourceTree = ""; }; 758714A62C6CF1EB008E5E4E /* AppEnvironments.struct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppEnvironments.struct.swift; sourceTree = ""; }; 75A4D60B2C5B8EAA00929368 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -118,6 +124,8 @@ 75E2378E2C334AA800119197 /* SdkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SdkTest.swift; sourceTree = ""; }; 75E237912C334AD200119197 /* DeviceIdTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdTest.swift; sourceTree = ""; }; 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushTargetResolverTests.swift; sourceTree = ""; }; + 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserIdentityRepositoryIsolationTests.swift; sourceTree = ""; }; + 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserIdentityRepositoryTests.swift; sourceTree = ""; }; 9A58AAB128E80C33004EA84C /* REESNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = REESNotificationServiceExtension.entitlements; sourceTree = ""; }; 9A58AAB228E80EEA004EA84C /* REESNotificationContentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = REESNotificationContentExtension.entitlements; sourceTree = ""; }; 9A7A95DE25BC40E900FFA40A /* REESNotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = REESNotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -286,6 +294,10 @@ B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */, 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */, 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */, + 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */, + 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */, + 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */, + 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -720,6 +732,10 @@ 6805322F7DF2A2E2F1234B24 /* InstanceResolverTests.swift in Sources */, 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */, 67B36DC5275BCA31AB0A0363 /* SdkRegistryTests.swift in Sources */, + DFDB6BC32790E30AA243B4E9 /* StoragePartitionTests.swift in Sources */, + F0605CC99B325F3301237A28 /* StorageMigrationTests.swift in Sources */, + F808067588CEF0AA10BCA7D7 /* UserIdentityRepositoryTests.swift in Sources */, + 8A5B5B19354E21262D482DEB /* UserIdentityRepositoryIsolationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/StorageMigrationTests.swift b/DemoApp/Rees46DemoTests/StorageMigrationTests.swift new file mode 100644 index 00000000..55cc0f97 --- /dev/null +++ b/DemoApp/Rees46DemoTests/StorageMigrationTests.swift @@ -0,0 +1,91 @@ +// +// StorageMigrationTests.swift +// REES46Tests +// +// Covers the one-time legacy→partition migration (R2). Uses isolated `UserDefaults` suites so the +// real `.standard` domain is never touched. Pins the identity guarantee (an upgrade keeps its did) +// and the consume-once rule (two shops never clone one identity). +// + +import XCTest +@testable import REES46 + +final class StorageMigrationTests: XCTestCase { + + private var suiteNames: [String] = [] + + private func freshSuite() -> UserDefaults { + let name = "test-\(UUID().uuidString)" + suiteNames.append(name) + return UserDefaults(suiteName: name)! + } + + override func tearDown() { + for name in suiteNames { + UserDefaults.standard.removePersistentDomain(forName: name) + } + suiteNames.removeAll() + super.tearDown() + } + + func test_migrates_legacy_identity_into_an_empty_partition() { + let legacy = freshSuite() + let store = freshSuite() + legacy.set("did-legacy", forKey: StoragePartition.deviceIdKey) + legacy.set("seance-legacy", forKey: StoragePartition.seanceKey) + legacy.set("idfa-legacy", forKey: StoragePartition.idfaKey) + + StorageMigration.migrateLegacyIfNeeded(into: store, legacy: legacy) + + XCTAssertEqual(store.string(forKey: StoragePartition.deviceIdKey), "did-legacy") + XCTAssertEqual(store.string(forKey: StoragePartition.seanceKey), "seance-legacy") + XCTAssertEqual(store.string(forKey: StoragePartition.idfaKey), "idfa-legacy") + XCTAssertTrue(legacy.bool(forKey: StoragePartition.legacyConsumedFlag)) + } + + func test_a_second_shop_does_not_inherit_after_the_first_consumed_the_legacy() { + let legacy = freshSuite() + let storeA = freshSuite() + let storeB = freshSuite() + legacy.set("did-legacy", forKey: StoragePartition.deviceIdKey) + + StorageMigration.migrateLegacyIfNeeded(into: storeA, legacy: legacy) + StorageMigration.migrateLegacyIfNeeded(into: storeB, legacy: legacy) + + XCTAssertEqual(storeA.string(forKey: StoragePartition.deviceIdKey), "did-legacy") + XCTAssertNil(storeB.string(forKey: StoragePartition.deviceIdKey)) + } + + func test_does_not_overwrite_an_already_partitioned_identity() { + let legacy = freshSuite() + let store = freshSuite() + store.set("did-existing", forKey: StoragePartition.deviceIdKey) + legacy.set("did-legacy", forKey: StoragePartition.deviceIdKey) + + StorageMigration.migrateLegacyIfNeeded(into: store, legacy: legacy) + + XCTAssertEqual(store.string(forKey: StoragePartition.deviceIdKey), "did-existing") + } + + func test_no_migration_and_no_consume_when_legacy_is_empty() { + let legacy = freshSuite() + let store = freshSuite() + + StorageMigration.migrateLegacyIfNeeded(into: store, legacy: legacy) + + XCTAssertNil(store.string(forKey: StoragePartition.deviceIdKey)) + XCTAssertFalse(legacy.bool(forKey: StoragePartition.legacyConsumedFlag)) + } + + func test_copies_only_device_id_when_seance_and_idfa_are_absent() { + let legacy = freshSuite() + let store = freshSuite() + legacy.set("did-legacy", forKey: StoragePartition.deviceIdKey) + + StorageMigration.migrateLegacyIfNeeded(into: store, legacy: legacy) + + XCTAssertEqual(store.string(forKey: StoragePartition.deviceIdKey), "did-legacy") + XCTAssertNil(store.string(forKey: StoragePartition.seanceKey)) + XCTAssertNil(store.string(forKey: StoragePartition.idfaKey)) + } +} diff --git a/DemoApp/Rees46DemoTests/StoragePartitionTests.swift b/DemoApp/Rees46DemoTests/StoragePartitionTests.swift new file mode 100644 index 00000000..0fce12c9 --- /dev/null +++ b/DemoApp/Rees46DemoTests/StoragePartitionTests.swift @@ -0,0 +1,63 @@ +// +// StoragePartitionTests.swift +// REES46Tests +// +// Covers the per-shop storage partition naming (R2). Pure logic — mirror of the stability guarantees +// Android pins for `PreferencesPartition`. +// + +import XCTest +@testable import REES46 + +final class StoragePartitionTests: XCTestCase { + + private var createdSuites: [String] = [] + + override func tearDown() { + for name in createdSuites { + UserDefaults.standard.removePersistentDomain(forName: name) + } + createdSuites.removeAll() + super.tearDown() + } + + func test_suiteName_carries_the_shared_prefix() { + XCTAssertEqual(StoragePartition.suiteName(for: "shop-a"), "personalization_sdk_shop-a") + } + + func test_sanitize_replaces_unsafe_characters_with_underscore() { + XCTAssertEqual(StoragePartition.sanitize("com.foo/bar id"), "com_foo_bar_id") + } + + func test_sanitize_preserves_safe_characters() { + XCTAssertEqual(StoragePartition.sanitize("Shop_123-AB"), "Shop_123-AB") + } + + func test_suiteName_is_stable_for_the_same_shop() { + XCTAssertEqual( + StoragePartition.suiteName(for: "shop-42"), + StoragePartition.suiteName(for: "shop-42") + ) + } + + func test_store_reads_back_what_it_writes() { + let shopId = "roundtrip-\(UUID().uuidString)" + createdSuites.append(StoragePartition.suiteName(for: shopId)) + + let store = StoragePartition.store(for: shopId) + store.set("value", forKey: "k") + + XCTAssertEqual(store.string(forKey: "k"), "value") + } + + func test_store_uses_the_custom_suite_when_a_storage_key_is_given() { + let custom = "custom-\(UUID().uuidString)" + createdSuites.append(custom) + + let store = StoragePartition.store(for: "ignored-shop", storageKey: custom) + store.set("v", forKey: "k") + + // The value must be visible through a separately-opened handle on the same custom suite. + XCTAssertEqual(UserDefaults(suiteName: custom)?.string(forKey: "k"), "v") + } +} diff --git a/DemoApp/Rees46DemoTests/UserIdentityRepositoryIsolationTests.swift b/DemoApp/Rees46DemoTests/UserIdentityRepositoryIsolationTests.swift new file mode 100644 index 00000000..b3810705 --- /dev/null +++ b/DemoApp/Rees46DemoTests/UserIdentityRepositoryIsolationTests.swift @@ -0,0 +1,113 @@ +// +// UserIdentityRepositoryIsolationTests.swift +// REES46Tests +// +// Multi-instance isolation for the identity repository: several shops live at once, and each must +// keep its own did/seance/IDFA with no collision. Repos are built over the real per-shop suite +// factory (`StoragePartition.store(for:)`) via the direct-store initializer, so the partition +// mechanism itself is under test with no `.standard`/migration noise. +// + +import XCTest +@testable import REES46 + +final class UserIdentityRepositoryIsolationTests: XCTestCase { + + private var suiteNames: [String] = [] + + /// A repository over [shopId]'s real partition suite, tracked for cleanup. + private func repo(forShop shopId: String) -> UserIdentityRepositoryImpl { + suiteNames.append(StoragePartition.suiteName(for: shopId)) + return UserIdentityRepositoryImpl(store: StoragePartition.store(for: shopId)) + } + + private func uniqueShop(_ label: String) -> String { + "\(label)-\(UUID().uuidString)" + } + + override func tearDown() { + for name in Set(suiteNames) { + UserDefaults.standard.removePersistentDomain(forName: name) + } + suiteNames.removeAll() + super.tearDown() + } + + func test_two_shops_have_fully_isolated_identity() { + let repoA = repo(forShop: uniqueShop("shop-a")) + let repoB = repo(forShop: uniqueShop("shop-b")) + + repoA.deviceId = "did-A"; repoA.seance = "seance-A"; repoA.advertisingId = "idfa-A" + repoB.deviceId = "did-B"; repoB.seance = "seance-B"; repoB.advertisingId = "idfa-B" + + XCTAssertEqual(repoA.deviceId, "did-A") + XCTAssertEqual(repoA.seance, "seance-A") + XCTAssertEqual(repoA.advertisingId, "idfa-A") + + XCTAssertEqual(repoB.deviceId, "did-B") + XCTAssertEqual(repoB.seance, "seance-B") + XCTAssertEqual(repoB.advertisingId, "idfa-B") + } + + func test_writing_one_shop_does_not_leak_into_another() { + let repoA = repo(forShop: uniqueShop("shop-a")) + let repoB = repo(forShop: uniqueShop("shop-b")) + + repoA.deviceId = "did-A" + + XCTAssertEqual(repoA.deviceId, "did-A") + XCTAssertNil(repoB.deviceId) + } + + func test_clearing_one_shop_leaves_the_other_intact() { + let repoA = repo(forShop: uniqueShop("shop-a")) + let repoB = repo(forShop: uniqueShop("shop-b")) + repoA.deviceId = "did-A"; repoA.seance = "seance-A" + repoB.deviceId = "did-B"; repoB.seance = "seance-B" + + repoA.clearIdentity() + + XCTAssertNil(repoA.deviceId) + XCTAssertNil(repoA.seance) + XCTAssertEqual(repoB.deviceId, "did-B") + XCTAssertEqual(repoB.seance, "seance-B") + } + + /// Two repositories for the SAME shop share one partition — a re-resolved/re-created instance + /// keeps the shop's identity (it is keyed by shop, not by object). + func test_same_shop_id_shares_one_partition() { + let shopId = uniqueShop("shop-shared") + let first = repo(forShop: shopId) + let second = repo(forShop: shopId) + + first.deviceId = "did-shared" + + XCTAssertEqual(second.deviceId, "did-shared") + } + + func test_three_shops_are_mutually_isolated() { + let repos = (0..<3).map { repo(forShop: uniqueShop("shop-\($0)")) } + for (index, repo) in repos.enumerated() { + repo.deviceId = "did-\(index)" + } + + let readBack = repos.map { $0.deviceId } + XCTAssertEqual(readBack, ["did-0", "did-1", "did-2"]) + XCTAssertEqual(Set(readBack.compactMap { $0 }).count, 3, "all three dids must be distinct") + } + + /// Naming-level guarantee: distinct shop ids never alias to the same suite (no collision at the + /// partition-name level for the realistic shop-id space — alphanumeric hex tokens plus `-`/`_`). + func test_distinct_shop_ids_map_to_distinct_suites() { + let shopIds = [ + "3704bd6e8a1a9a6dc65b75a5f01e5c8d", + "9a1b2c3d4e5f60718293a4b5c6d7e8f9", + "shop-a", + "shop-b", + "Shop_A", + "shop-a-2", + ] + let suites = Set(shopIds.map { StoragePartition.suiteName(for: $0) }) + XCTAssertEqual(suites.count, shopIds.count, "each distinct shop id must get its own suite") + } +} diff --git a/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift b/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift new file mode 100644 index 00000000..7c27e941 --- /dev/null +++ b/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift @@ -0,0 +1,105 @@ +// +// UserIdentityRepositoryTests.swift +// REES46Tests +// +// Covers the typed identity port and its UserDefaults adapter (R2). The accessor↔key mapping and +// clear semantics are pinned here; the legacy migration itself is covered by StorageMigrationTests. +// + +import XCTest +@testable import REES46 + +/// In-memory identity store — proves the port is fake-able for future two-shop SDK isolation tests. +final class InMemoryUserIdentityRepository: UserIdentityRepository { + var deviceId: String? + var seance: String? + var advertisingId: String? + func removeDeviceId() { deviceId = nil } + func clearIdentity() { deviceId = nil; seance = nil } +} + +final class UserIdentityRepositoryTests: XCTestCase { + + private var suiteNames: [String] = [] + + private func freshSuite() -> UserDefaults { + let name = "test-\(UUID().uuidString)" + suiteNames.append(name) + return UserDefaults(suiteName: name)! + } + + override func tearDown() { + for name in suiteNames { + UserDefaults.standard.removePersistentDomain(forName: name) + } + suiteNames.removeAll() + super.tearDown() + } + + func test_reads_back_what_it_writes() { + let repo = UserIdentityRepositoryImpl(store: freshSuite()) + + repo.deviceId = "did-1" + repo.seance = "seance-1" + repo.advertisingId = "idfa-1" + + XCTAssertEqual(repo.deviceId, "did-1") + XCTAssertEqual(repo.seance, "seance-1") + XCTAssertEqual(repo.advertisingId, "idfa-1") + } + + func test_removeDeviceId_clears_only_the_device_id() { + let repo = UserIdentityRepositoryImpl(store: freshSuite()) + repo.deviceId = "did-1" + repo.seance = "seance-1" + + repo.removeDeviceId() + + XCTAssertNil(repo.deviceId) + XCTAssertEqual(repo.seance, "seance-1") + } + + func test_clearIdentity_clears_device_id_and_seance_but_not_advertising_id() { + let repo = UserIdentityRepositoryImpl(store: freshSuite()) + repo.deviceId = "did-1" + repo.seance = "seance-1" + repo.advertisingId = "idfa-1" + + repo.clearIdentity() + + XCTAssertNil(repo.deviceId) + XCTAssertNil(repo.seance) + XCTAssertEqual(repo.advertisingId, "idfa-1") + } + + func test_writes_land_in_the_custom_suite_when_a_storage_key_is_given() { + let custom = "custom-\(UUID().uuidString)" + suiteNames.append(custom) + + let repo = UserIdentityRepositoryImpl(shopId: "ignored-shop", storageKey: custom) + repo.deviceId = "did-custom" + + XCTAssertEqual( + UserDefaults(suiteName: custom)?.string(forKey: StoragePartition.deviceIdKey), + "did-custom" + ) + } + + func test_persists_into_the_shop_partition_suite() { + let shopId = "shop-\(UUID().uuidString)" + suiteNames.append(StoragePartition.suiteName(for: shopId)) + + let repo = UserIdentityRepositoryImpl(shopId: shopId, storageKey: nil) + repo.deviceId = "did-shop" + + let partition = UserDefaults(suiteName: StoragePartition.suiteName(for: shopId)) + XCTAssertEqual(partition?.string(forKey: StoragePartition.deviceIdKey), "did-shop") + } + + func test_in_memory_fake_round_trips() { + let repo: UserIdentityRepository = InMemoryUserIdentityRepository() + repo.deviceId = "did" + repo.clearIdentity() + XCTAssertNil(repo.deviceId) + } +} diff --git a/REES46/Classes/Sdk/Storage/StorageMigration.swift b/REES46/Classes/Sdk/Storage/StorageMigration.swift new file mode 100644 index 00000000..9fc7353a --- /dev/null +++ b/REES46/Classes/Sdk/Storage/StorageMigration.swift @@ -0,0 +1,60 @@ +// +// StorageMigration.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + One-time migration of the pre-partition identity into a shop's partition (R2). + + Before per-shop partitioning, `device_id` / `seance_id` / `IDFA` lived in `UserDefaults.standard`. + On upgrade the first shop to initialize must inherit that identity so an existing install keeps its + did (no silent re-registration). This copies the legacy values from `.standard` into the shop's + partition once, then marks the legacy source consumed. + + iOS difference from Android: the legacy `.standard` carries no `shop_id` marker next to the did, so + "does this legacy identity belong to this shop?" can't be answered directly. Instead the adoption is + **consume-once**: the first shop to initialize inherits it and sets [StoragePartition.legacyConsumedFlag]; + a second shop then finds it consumed and gets its own fresh id from the server — so two shops never + clone one identity. Pre-partition installs were single-shop and a binary's primary `shop_id` is fixed + across versions, so the shop that adopts is the one that owned the did. + */ +enum StorageMigration { + + /** + Copies the legacy identity from [legacy] into [store] exactly once across the app, guarded so it + runs only when the partition has no did yet, the legacy source has one, and no shop has consumed + it before. Non-destructive to the legacy values (only the consumed flag is written), and a no-op + on fresh installs (nothing to copy) and on already-partitioned installs (partition non-empty). + */ + static func migrateLegacyIfNeeded( + into store: UserDefaults, + legacy: UserDefaults = .standard + ) { + // Already partitioned for this shop — nothing to migrate. + let existing = store.string(forKey: StoragePartition.deviceIdKey) ?? "" + guard existing.isEmpty else { return } + + // Another shop already inherited the legacy identity — this one starts fresh. + guard !legacy.bool(forKey: StoragePartition.legacyConsumedFlag) else { return } + + // Fresh install with no pre-partition identity — nothing to migrate. + guard let legacyDeviceId = legacy.string(forKey: StoragePartition.deviceIdKey), + !legacyDeviceId.isEmpty else { return } + + store.set(legacyDeviceId, forKey: StoragePartition.deviceIdKey) + if let seance = legacy.string(forKey: StoragePartition.seanceKey) { + store.set(seance, forKey: StoragePartition.seanceKey) + } + if let idfa = legacy.string(forKey: StoragePartition.idfaKey) { + store.set(idfa, forKey: StoragePartition.idfaKey) + } + + // The first shop to initialize inherits the pre-partition identity; later shops get their own. + legacy.set(true, forKey: StoragePartition.legacyConsumedFlag) + } +} diff --git a/REES46/Classes/Sdk/Storage/StoragePartition.swift b/REES46/Classes/Sdk/Storage/StoragePartition.swift new file mode 100644 index 00000000..f4318573 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/StoragePartition.swift @@ -0,0 +1,65 @@ +// +// StoragePartition.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Naming and construction of the SDK's per-shop `UserDefaults` partitions. + + Multi-instance storage (R2): identity and session state are partitioned per `shop_id` so two shops + in one app do not share one domain. Each shop gets its own `UserDefaults(suiteName:)` — a separate + `personalization_sdk_.plist` — keeping the keys themselves unchanged. Mirror of the Android + `PreferencesPartition` (same `personalization_sdk_` prefix and filename-safe sanitising), so the two + platforms share one mental model and one migration story. + + Only per-device state that is genuinely shop-scoped lives here: `device_id` (the did), the persisted + `seance_id`, and the advertising id. The APNs/FCM **push token stays global** (`.standard`): one token + per app install, fanned out to every shop's backend — see the push-token path. + */ +enum StoragePartition { + + /// Prefix for a shop's suite name. Must match Android `PreferencesPartition.PREFIX`. Stable + /// forever: changing it would orphan every existing install's partition. + static let prefix = "personalization_sdk_" + + // Keys that are partitioned per shop (identity + session + advertising id). + static let deviceIdKey = SdkConstants.deviceIdKey // "device_id" + static let seanceKey = "seance_id" + static let idfaKey = "IDFA" + + /// Global flag (kept in the legacy `.standard` domain) marking the one-time legacy adoption as + /// consumed, so only the first shop to initialize inherits the pre-partition identity. + static let legacyConsumedFlag = "personalization_sdk_legacy_consumed" + + private static let unsafeFilenameChars = "[^A-Za-z0-9_-]" + + /// Filename-safe form of [shopId] for use inside a suite name. + static func sanitize(_ shopId: String) -> String { + shopId.replacingOccurrences( + of: unsafeFilenameChars, + with: "_", + options: .regularExpression + ) + } + + /// Stable suite name for [shopId]'s partition. + static func suiteName(for shopId: String) -> String { + prefix + sanitize(shopId) + } + + /** + The `UserDefaults` a shop should read/write. Defaults to the per-shop suite; a host that passes + its own [storageKey] gets that suite verbatim (parity with Android's `preferencesKey`, and the + opt-out from migration). Falls back to `.standard` only if the suite can't be created — which + happens solely for reserved names (the app's bundle id), never for our prefixed names. + */ + static func store(for shopId: String, storageKey: String? = nil) -> UserDefaults { + let name = storageKey ?? suiteName(for: shopId) + return UserDefaults(suiteName: name) ?? .standard + } +} diff --git a/REES46/Classes/Sdk/Storage/UserIdentityRepository.swift b/REES46/Classes/Sdk/Storage/UserIdentityRepository.swift new file mode 100644 index 00000000..9da7c76e --- /dev/null +++ b/REES46/Classes/Sdk/Storage/UserIdentityRepository.swift @@ -0,0 +1,40 @@ +// +// UserIdentityRepository.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + The SDK's per-shop identity/session storage, behind a typed port. + + Owns exactly the state that is scoped to a `shop_id`: the device id (`did`), the persisted session + (`seance`/`sid`), and the advertising id (`IDFA`). Push tokens are device-global and deliberately not + here. Mirrors the concept of the Android `UserSettingsRepository` (typed accessors over the store), + without importing its use-case ceremony. + + The port exists so the ~dozen call sites in `SimplePersonalizationSDK` stop poking `UserDefaults` + with raw string keys (which had already caused a stringly-typed bug), so the "partitioned per shop, + token global" policy has a single owner, and so an in-memory implementation can be injected to test + two-shop isolation without touching real storage or the network. + */ +protocol UserIdentityRepository: AnyObject { + + /// The permanent device id (`did`), or nil when none is stored yet. + var deviceId: String? { get set } + + /// The persisted session id (`seance`/`sid`). + var seance: String? { get set } + + /// The advertising id (`IDFA`). + var advertisingId: String? { get set } + + /// Removes only the device id — used by the forced re-initialization path. + func removeDeviceId() + + /// Clears the user's identity (device id + session) — backs `deleteUserCredentials`. + func clearIdentity() +} diff --git a/REES46/Classes/Sdk/Storage/UserIdentityRepositoryImpl.swift b/REES46/Classes/Sdk/Storage/UserIdentityRepositoryImpl.swift new file mode 100644 index 00000000..9b482502 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/UserIdentityRepositoryImpl.swift @@ -0,0 +1,63 @@ +// +// UserIdentityRepositoryImpl.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + `UserDefaults`-backed [UserIdentityRepository]: reads and writes the shop's identity in a per-shop + partition ([StoragePartition]) instead of the shared `.standard` domain, so two shops in one app do + not clone one identity. + + On the default partition it runs the one-time legacy adoption ([StorageMigration]) at construction, + so an existing (pre-partition) install keeps its did with no silent re-registration. A host that + supplies its own `storageKey` gets that suite verbatim and no migration — parity with Android's + `preferencesKey`. + */ +final class UserIdentityRepositoryImpl: UserIdentityRepository { + + private let store: UserDefaults + + /// Builds the repository over [shopId]'s partition, migrating the legacy identity in on the + /// default partition. Pass [storageKey] to use a custom suite verbatim (no migration). + init(shopId: String, storageKey: String? = nil) { + let store = StoragePartition.store(for: shopId, storageKey: storageKey) + self.store = store + if storageKey == nil { + StorageMigration.migrateLegacyIfNeeded(into: store) + } + } + + /// Direct-store initializer for tests/DI — no migration, no shop-name derivation. + init(store: UserDefaults) { + self.store = store + } + + var deviceId: String? { + get { store.string(forKey: StoragePartition.deviceIdKey) } + set { store.set(newValue, forKey: StoragePartition.deviceIdKey) } + } + + var seance: String? { + get { store.string(forKey: StoragePartition.seanceKey) } + set { store.set(newValue, forKey: StoragePartition.seanceKey) } + } + + var advertisingId: String? { + get { store.string(forKey: StoragePartition.idfaKey) } + set { store.set(newValue, forKey: StoragePartition.idfaKey) } + } + + func removeDeviceId() { + store.removeObject(forKey: StoragePartition.deviceIdKey) + } + + func clearIdentity() { + store.set(nil, forKey: StoragePartition.deviceIdKey) + store.set(nil, forKey: StoragePartition.seanceKey) + } +} diff --git a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift index 604fc6c5..f2139b3c 100644 --- a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift +++ b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift @@ -31,6 +31,11 @@ class SimplePersonalizationSDK: PersonalizationSDK { let autoSendPushToken: Bool let needReInitialization: Bool let sendAdvertisingId: Bool + + /// This shop's identity/session storage (R2): `device_id` / `seance` / `IDFA` are read and written + /// through this typed port, backed by a per-shop partition so two shops in one app do not share + /// identity. The push token stays device-global. See [UserIdentityRepository]. + let userIdentity: UserIdentityRepository let sdkBundleId = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier let appBundleId = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier @@ -101,9 +106,17 @@ class SimplePersonalizationSDK: PersonalizationSDK { parentViewController: UIViewController? = nil, enableAutoPopupPresentation: Bool = true, needReInitialization: Bool = false, + storageKey: String? = nil, + userIdentity: UserIdentityRepository? = nil, completion: ((SdkError?) -> Void)? = nil ) { self.shopId = shopId + + // R2: identity/session live in this shop's partition. The default repository migrates the + // pre-partition identity in once (existing installs keep their did); an injected repository + // (tests) is used verbatim. + self.userIdentity = userIdentity ?? UserIdentityRepositoryImpl(shopId: shopId, storageKey: storageKey) + self.autoSendPushToken = autoSendPushToken self.parentViewController = parentViewController self.enableAutoPopupPresentation = enableAutoPopupPresentation @@ -123,7 +136,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { segment = ["A", "B"].randomElement() ?? "A" // Fetch user session (permanent user Id) - deviceId = UserDefaults.standard.string(forKey: SdkConstants.deviceIdKey) ?? "" + deviceId = self.userIdentity.deviceId ?? "" urlSession = URLSession.shared sessionQueue.addOperation { @@ -1173,15 +1186,15 @@ class SimplePersonalizationSDK: PersonalizationSDK { ] if needReInitialization { - UserDefaults.standard.removeObject(forKey: SdkConstants.deviceIdKey) + userIdentity.removeDeviceId() print("USER DEVICE ID WAS REMOVED") } - - if let deviceId = UserDefaults.standard.string(forKey: SdkConstants.deviceIdKey), !deviceId.isEmpty { + + if let deviceId = userIdentity.deviceId, !deviceId.isEmpty { params[SdkConstants.did] = deviceId } - - if let advId = UserDefaults.standard.string(forKey: "IDFA"), advId != "00000000-0000-0000-0000-000000000000" { + + if let advId = userIdentity.advertisingId, advId != "00000000-0000-0000-0000-000000000000" { params["ios_advertising_id"] = advId } @@ -1208,11 +1221,11 @@ class SimplePersonalizationSDK: PersonalizationSDK { initFileNamePath: URL, completion: @escaping (Result) -> Void ) { - let keychainDid = UserDefaults.standard.string(forKey: SdkConstants.deviceIdKey) ?? "" + let keychainDid = userIdentity.deviceId ?? "" if keychainDid.isEmpty || needReInitialization { persistDeviceIdIfAbsent(resultResponse.deviceId) - UserDefaults.standard.set(resultResponse.seance, forKey: "seance_id") + userIdentity.seance = resultResponse.seance sleep(1) completion(.success(resultResponse)) } else { @@ -1288,17 +1301,17 @@ class SimplePersonalizationSDK: PersonalizationSDK { SdkConstants.tz: String(hours) ] - let dId = UserDefaults.standard.string(forKey: SdkConstants.deviceIdKey) ?? "" + let dId = userIdentity.deviceId ?? "" if dId != "" { params[SdkConstants.did] = dId } - + let advId = idfa.uuidString if advId == "00000000-0000-0000-0000-000000000000" || advId == "" { return } params["ios_advertising_id"] = advId - UserDefaults.standard.set(advId, forKey: "IDFA") + userIdentity.advertisingId = advId let sessionConfig = URLSessionConfiguration.default sessionConfig.timeoutIntervalForRequest = 1 @@ -1322,8 +1335,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { let initFileNamePath = SdkGlobalHelper.sharedInstance.getSdkDocumentsDirectory().appendingPathComponent(convertedInitJsonFileName) try? FileManager.default.removeItem(at: initFileNamePath) - UserDefaults.standard.set(nil, forKey: SdkConstants.deviceIdKey) - UserDefaults.standard.set(nil, forKey: "seance_id") + userIdentity.clearIdentity() // Multi-instance groundwork (R1): this instance is torn down — drop it from push routing so a // superseded object is not fed push tokens or resolved by `shop_id`. @@ -1370,7 +1382,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { func storeSuccessInit(result: InitResponse) { persistDeviceIdIfAbsent(result.deviceId) - UserDefaults.standard.set(result.seance, forKey: "seance_id") + userIdentity.seance = result.seance } private static let deviceIdPersistLock = NSObject() @@ -1384,9 +1396,9 @@ class SimplePersonalizationSDK: PersonalizationSDK { guard let deviceId = deviceId, !deviceId.isEmpty else { return } objc_sync_enter(Self.deviceIdPersistLock) defer { objc_sync_exit(Self.deviceIdPersistLock) } - let current = UserDefaults.standard.string(forKey: SdkConstants.deviceIdKey) ?? "" + let current = userIdentity.deviceId ?? "" if current.isEmpty { - UserDefaults.standard.set(deviceId, forKey: SdkConstants.deviceIdKey) + userIdentity.deviceId = deviceId } } @@ -1538,7 +1550,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { if self.deviceId == "" { self.sessionQueue.pause() sleep(5) - let dId = UserDefaults.standard.string(forKey: "device_id") ?? "" + let dId = self.userIdentity.deviceId ?? "" self.deviceId = dId requestParams["did"] = dId self.sessionQueue.resume() From 59bf5fbee496b9824c0c1799be9e0b87ab1168bd Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 16:41:16 +0400 Subject: [PATCH 03/10] feat(sdk): partition the keychain init-secret per shop_id Key the keychain backup of the init secret by shop (account = ".") instead of the bundle id alone, so two shops in one app no longer share the item and a reinstall restores each shop's own did. A one-time adoption copies the pre-partition item into the first shop's slot and then removes it, so an existing install keeps its did across a reinstall without a second shop cloning it. Route keychain read/write through a KeychainInitStore port, and clear it in deleteUserCredentials (previously the backup outlived a credentials wipe and resurrected the deleted did). Cover the account naming and the store (round-trip, two-shop isolation, legacy adoption) with unit tests. --- DemoApp/REES46.xcodeproj/project.pbxproj | 8 ++ .../KeychainInitStoreTests.swift | 111 ++++++++++++++++++ .../KeychainPartitionTests.swift | 31 +++++ .../Sdk/Storage/KeychainInitStore.swift | 29 +++++ .../Sdk/Storage/KeychainInitStoreImpl.swift | 64 ++++++++++ .../Sdk/Storage/KeychainPartition.swift | 32 +++++ .../Sdk/impl/SimplePersonalizationSDK.swift | 20 +++- 7 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/KeychainInitStoreTests.swift create mode 100644 DemoApp/Rees46DemoTests/KeychainPartitionTests.swift create mode 100644 REES46/Classes/Sdk/Storage/KeychainInitStore.swift create mode 100644 REES46/Classes/Sdk/Storage/KeychainInitStoreImpl.swift create mode 100644 REES46/Classes/Sdk/Storage/KeychainPartition.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index a223c2c0..0c41b056 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0169B35A9F893F6D8D22820A /* Pods_REES46Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */; }; + 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */; }; 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */; }; 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 607FACD81AFB9204008FA782 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* MainViewController.swift */; }; @@ -41,6 +42,7 @@ A1B2C3D40000000000000003 /* GetUserOrdersTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000004 /* GetUserOrdersTest.swift */; }; A1B2C3D40000000000000005 /* LoyaltyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000006 /* LoyaltyTests.swift */; }; A1B2C3D40000000000000007 /* CatalogReadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000008 /* CatalogReadTests.swift */; }; + CFA0CA171D71D6AB7A4895CC /* KeychainPartitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */; }; D005D80D2DB2CDE900841D78 /* MockGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D005D80C2DB2CDE000841D78 /* MockGenerator.swift */; }; D0763E5A2DB8F51D0013E367 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0763E592DB8F5170013E367 /* Constants.swift */; }; D0B70C172D522DAF0001782A /* SearchServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B70C162D522DAF0001782A /* SearchServiceTests.swift */; }; @@ -105,6 +107,7 @@ 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoragePartitionTests.swift; sourceTree = ""; }; 53578CC38C4B3A4AA9973CCC /* REES46.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = REES46.podspec; path = ../REES46.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 5AE5CCEEB00A5EF1E5E39AFF /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; + 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeychainPartitionTests.swift; sourceTree = ""; }; 607FACD01AFB9204008FA782 /* REES46Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = REES46Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACD41AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 607FACD51AFB9204008FA782 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -153,6 +156,7 @@ AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FakeSDK.swift; sourceTree = ""; }; B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InstanceResolverTests.swift; sourceTree = ""; }; B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeychainInitStoreTests.swift; sourceTree = ""; }; D005D80C2DB2CDE000841D78 /* MockGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenerator.swift; sourceTree = ""; }; D0763E592DB8F5170013E367 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D0B70C162D522DAF0001782A /* SearchServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceTests.swift; sourceTree = ""; }; @@ -298,6 +302,8 @@ 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */, 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */, 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */, + 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */, + CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -736,6 +742,8 @@ F0605CC99B325F3301237A28 /* StorageMigrationTests.swift in Sources */, F808067588CEF0AA10BCA7D7 /* UserIdentityRepositoryTests.swift in Sources */, 8A5B5B19354E21262D482DEB /* UserIdentityRepositoryIsolationTests.swift in Sources */, + CFA0CA171D71D6AB7A4895CC /* KeychainPartitionTests.swift in Sources */, + 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/KeychainInitStoreTests.swift b/DemoApp/Rees46DemoTests/KeychainInitStoreTests.swift new file mode 100644 index 00000000..cefc438c --- /dev/null +++ b/DemoApp/Rees46DemoTests/KeychainInitStoreTests.swift @@ -0,0 +1,111 @@ +// +// KeychainInitStoreTests.swift +// REES46Tests +// +// Covers the per-shop keychain init-secret store (R2) against the real keychain. Each test uses a +// unique service so items never collide with the app's own keychain or with other tests, and wipes +// that service in tearDown. +// + +import XCTest +import Security +@testable import REES46 + +final class KeychainInitStoreTests: XCTestCase { + + private var services: [String] = [] + + private func uniqueService() -> String { + let service = "test-keychain-\(UUID().uuidString)" + services.append(service) + return service + } + + private func blob(_ s: String) -> Data { Data(s.utf8) } + + override func tearDown() { + for service in services { + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service + ] as CFDictionary + SecItemDelete(query) + } + services.removeAll() + super.tearDown() + } + + func test_writes_and_reads_back_the_init_blob() { + let store = KeychainInitStoreImpl(shopId: "A", service: uniqueService()) + + store.writeInitData(blob("init-A")) + + XCTAssertEqual(store.readInitData(), blob("init-A")) + } + + func test_clear_removes_the_blob() { + let store = KeychainInitStoreImpl(shopId: "A", service: uniqueService()) + store.writeInitData(blob("init-A")) + + store.clear() + + XCTAssertNil(store.readInitData()) + } + + func test_two_shops_have_isolated_keychain_items() { + let service = uniqueService() + let storeA = KeychainInitStoreImpl(shopId: "A", service: service) + let storeB = KeychainInitStoreImpl(shopId: "B", service: service) + + storeA.writeInitData(blob("init-A")) + storeB.writeInitData(blob("init-B")) + + XCTAssertEqual(storeA.readInitData(), blob("init-A")) + XCTAssertEqual(storeB.readInitData(), blob("init-B")) + + storeA.clear() + XCTAssertNil(storeA.readInitData()) + XCTAssertEqual(storeB.readInitData(), blob("init-B"), "clearing one shop must not touch another") + } + + func test_first_shop_adopts_the_legacy_item_then_consumes_it() { + let service = uniqueService() + // Seed a pre-partition item at the legacy account (== the service). + try? InitService.upsertKeychainDidToken( + blob("legacy-did"), + identifier: KeychainPartition.legacyAccount(service: service), + instanceKeychainService: service + ) + + // The first shop adopts it on construction. + let storeA = KeychainInitStoreImpl(shopId: "A", service: service) + XCTAssertEqual(storeA.readInitData(), blob("legacy-did")) + + // The legacy item is consumed, so a second shop does not clone the identity. + let storeB = KeychainInitStoreImpl(shopId: "B", service: service) + XCTAssertNil(storeB.readInitData()) + + // And the legacy account itself is gone. + let legacy = try? InitService.getKeychainDidToken( + identifier: KeychainPartition.legacyAccount(service: service), + instanceKeychainService: service + ) + XCTAssertNil(legacy) + } + + func test_does_not_adopt_legacy_when_the_shop_already_has_its_own() { + let service = uniqueService() + let storeA = KeychainInitStoreImpl(shopId: "A", service: service) + storeA.writeInitData(blob("own-A")) + + // Now a legacy item appears; re-constructing the same shop must keep its own blob. + try? InitService.upsertKeychainDidToken( + blob("legacy-did"), + identifier: KeychainPartition.legacyAccount(service: service), + instanceKeychainService: service + ) + let reopened = KeychainInitStoreImpl(shopId: "A", service: service) + + XCTAssertEqual(reopened.readInitData(), blob("own-A")) + } +} diff --git a/DemoApp/Rees46DemoTests/KeychainPartitionTests.swift b/DemoApp/Rees46DemoTests/KeychainPartitionTests.swift new file mode 100644 index 00000000..7f6039d4 --- /dev/null +++ b/DemoApp/Rees46DemoTests/KeychainPartitionTests.swift @@ -0,0 +1,31 @@ +// +// KeychainPartitionTests.swift +// REES46Tests +// +// Covers the per-shop keychain account naming (R2). Pure logic. +// + +import XCTest +@testable import REES46 + +final class KeychainPartitionTests: XCTestCase { + + func test_account_namespaces_the_shop_under_the_service() { + XCTAssertEqual(KeychainPartition.account(shopId: "shop-a", service: "com.app"), "com.app.shop-a") + } + + func test_legacy_account_is_the_service_itself() { + XCTAssertEqual(KeychainPartition.legacyAccount(service: "com.app"), "com.app") + } + + func test_distinct_shops_get_distinct_accounts() { + let a = KeychainPartition.account(shopId: "shop-a", service: "com.app") + let b = KeychainPartition.account(shopId: "shop-b", service: "com.app") + XCTAssertNotEqual(a, b) + } + + func test_a_shop_account_never_equals_the_legacy_account() { + let account = KeychainPartition.account(shopId: "shop-a", service: "com.app") + XCTAssertNotEqual(account, KeychainPartition.legacyAccount(service: "com.app")) + } +} diff --git a/REES46/Classes/Sdk/Storage/KeychainInitStore.swift b/REES46/Classes/Sdk/Storage/KeychainInitStore.swift new file mode 100644 index 00000000..82045530 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/KeychainInitStore.swift @@ -0,0 +1,29 @@ +// +// KeychainInitStore.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + The keychain-backed backup of a shop's init secret, behind a typed port. + + The init secret (an `InitResponse` JSON blob carrying the `did`) is mirrored into the keychain so a + shop's identity survives an app reinstall. Partitioned per shop (see [KeychainPartition]) so two + shops never share one item. The port keeps the keychain plumbing out of `SimplePersonalizationSDK` + and lets an in-memory implementation stand in for tests. + */ +protocol KeychainInitStore: AnyObject { + + /// The stored init blob for this shop, or nil if none / unavailable. + func readInitData() -> Data? + + /// Stores (or replaces) the init blob for this shop. + func writeInitData(_ data: Data) + + /// Removes this shop's stored init blob. + func clear() +} diff --git a/REES46/Classes/Sdk/Storage/KeychainInitStoreImpl.swift b/REES46/Classes/Sdk/Storage/KeychainInitStoreImpl.swift new file mode 100644 index 00000000..1cc3016d --- /dev/null +++ b/REES46/Classes/Sdk/Storage/KeychainInitStoreImpl.swift @@ -0,0 +1,64 @@ +// +// KeychainInitStoreImpl.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Keychain-backed [KeychainInitStore]: reads and writes the shop's init blob under a per-shop account + ([KeychainPartition]) via [InitService], instead of the single bundle-id-keyed item all shops used to + share. + + On construction it runs a one-time adoption of the pre-partition item so an existing (upgraded) + install keeps its did across a reinstall: the first shop to initialize copies the legacy blob into + its own account and then deletes the legacy item (consume-by-delete), so a second shop cannot clone + the same identity. + */ +final class KeychainInitStoreImpl: KeychainInitStore { + + private let service: String + private let account: String + + init(shopId: String, service: String) { + self.service = service + self.account = KeychainPartition.account(shopId: shopId, service: service) + adoptLegacyIfNeeded() + } + + func readInitData() -> Data? { + try? InitService.getKeychainDidToken(identifier: account, instanceKeychainService: service) + } + + func writeInitData(_ data: Data) { + try? InitService.upsertKeychainDidToken(data, identifier: account, instanceKeychainService: service) + } + + func clear() { + try? InitService.deleteKeychainDidToken(identifier: account, instanceKeychainService: service) + } + + /// Copies the pre-partition keychain blob into this shop's account exactly once, then removes the + /// legacy item. No-op when this shop already has its own blob, when nothing legacy exists, or when + /// a previous shop already consumed it. + private func adoptLegacyIfNeeded() { + // Already partitioned for this shop — nothing to adopt. + if (try? InitService.getKeychainDidToken(identifier: account, instanceKeychainService: service)) != nil { + return + } + + let legacyAccount = KeychainPartition.legacyAccount(service: service) + guard legacyAccount != account, + let legacy = try? InitService.getKeychainDidToken( + identifier: legacyAccount, + instanceKeychainService: service + ) + else { return } + + try? InitService.upsertKeychainDidToken(legacy, identifier: account, instanceKeychainService: service) + try? InitService.deleteKeychainDidToken(identifier: legacyAccount, instanceKeychainService: service) + } +} diff --git a/REES46/Classes/Sdk/Storage/KeychainPartition.swift b/REES46/Classes/Sdk/Storage/KeychainPartition.swift new file mode 100644 index 00000000..4b836ff0 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/KeychainPartition.swift @@ -0,0 +1,32 @@ +// +// KeychainPartition.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Naming of the SDK's per-shop keychain items (R2). + + The SDK backs up the init secret (the blob that carries the `did`) in the keychain so identity + survives an app **reinstall** — the keychain outlives the app container. Before partitioning the item + was keyed by the bundle id alone (`service = account = bundleId`), so two shops in one app resolved to + the same item and shop B could read shop A's did. Here the `service` stays the bundle id (namespace) + and the `account` carries the `shop_id`, so each shop gets its own keychain slot. + */ +enum KeychainPartition { + + /// The keychain account for [shopId]'s init secret, namespaced under [service] (the bundle id). + static func account(shopId: String, service: String) -> String { + "\(service).\(shopId)" + } + + /// The pre-partition account (the bundle id itself) — the one-time migration source. Existing + /// installs stored their only item here. + static func legacyAccount(service: String) -> String { + service + } +} diff --git a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift index f2139b3c..8f79dcf8 100644 --- a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift +++ b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift @@ -36,6 +36,10 @@ class SimplePersonalizationSDK: PersonalizationSDK { /// through this typed port, backed by a per-shop partition so two shops in one app do not share /// identity. The push token stays device-global. See [UserIdentityRepository]. let userIdentity: UserIdentityRepository + + /// This shop's keychain-backed init-secret backup (R2), keyed per shop so the did that survives a + /// reinstall belongs to this shop only. See [KeychainInitStore]. + let keychainStore: KeychainInitStore let sdkBundleId = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier let appBundleId = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier @@ -108,6 +112,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { needReInitialization: Bool = false, storageKey: String? = nil, userIdentity: UserIdentityRepository? = nil, + keychainStore: KeychainInitStore? = nil, completion: ((SdkError?) -> Void)? = nil ) { self.shopId = shopId @@ -117,6 +122,11 @@ class SimplePersonalizationSDK: PersonalizationSDK { // (tests) is used verbatim. self.userIdentity = userIdentity ?? UserIdentityRepositoryImpl(shopId: shopId, storageKey: storageKey) + // R2: the keychain init-secret backup is keyed per shop, adopting the legacy item once so a + // reinstall keeps this shop's did. Injected (tests) is used verbatim. + let keychainService = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier ?? "com.rees46.sdk" + self.keychainStore = keychainStore ?? KeychainInitStoreImpl(shopId: shopId, service: keychainService) + self.autoSendPushToken = autoSendPushToken self.parentViewController = parentViewController self.enableAutoPopupPresentation = enableAutoPopupPresentation @@ -1236,10 +1246,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { } private func handleKeychainInitData(initFileNamePath: URL) -> InitResponse? { - guard let keychainIpfsSecret = try? InitService.getKeychainDidToken( - identifier: sdkBundleId!, - instanceKeychainService: appBundleId! - ), + guard let keychainIpfsSecret = keychainStore.readInitData(), let jsonSecret = try? JSONSerialization.jsonObject(with: keychainIpfsSecret) as? [String: Any] else { return nil } @@ -1336,6 +1343,9 @@ class SimplePersonalizationSDK: PersonalizationSDK { try? FileManager.default.removeItem(at: initFileNamePath) userIdentity.clearIdentity() + // Also drop the keychain backup — otherwise a re-init would restore the very did we just + // deleted from its reinstall-surviving copy. + keychainStore.clear() // Multi-instance groundwork (R1): this instance is torn down — drop it from push routing so a // superseded object is not fed push tokens or resolved by `shop_id`. @@ -1453,7 +1463,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { if isInit { let convertedInitJsonFileName = self.shopId + self.baseInitJsonFileName try self.saveDataToJsonFile(data, jsonInitFileName: convertedInitJsonFileName) - try InitService.insertKeychainDidToken(data, identifier: self.sdkBundleId!, instanceKeychainService: self.appBundleId!) + self.keychainStore.writeInitData(data) } let json = try JSONSerialization.jsonObject(with: data) From eb8caaf2af121a3149ea6cc2433aef20da8c6b5c Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 19:36:13 +0400 Subject: [PATCH 04/10] feat(sdk): client-owned session with a rolling 2h TTL, per shop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the session id through a new SessionManager (injectable Clock and id generator): reuse the persisted seance while last activity is within 2 hours (the window rolls forward), otherwise start a fresh 10-character alphanumeric one. The activity anchor (seanceLastActTime) lives in the per-shop identity partition, so the session survives cold starts and stays isolated per shop — aligning iOS with the Android and React Native SDKs. The session is now client-owned: the server-assigned seance is no longer adopted, so continuity no longer depends on the backend echoing it. The device id is unchanged. Cover reuse / expiry / rolling refresh / generation and the timestamp round-trip with unit tests. --- DemoApp/REES46.xcodeproj/project.pbxproj | 4 + .../Rees46DemoTests/SessionManagerTests.swift | 124 ++++++++++++++++++ .../UserIdentityRepositoryTests.swift | 14 +- REES46/Classes/Sdk/Session/Clock.swift | 20 +++ .../Classes/Sdk/Session/SessionManager.swift | 25 ++++ .../Sdk/Session/SessionManagerImpl.swift | 60 +++++++++ .../Sdk/Storage/StoragePartition.swift | 1 + .../Sdk/Storage/UserIdentityRepository.swift | 4 + .../Storage/UserIdentityRepositoryImpl.swift | 17 +++ .../Sdk/impl/SimplePersonalizationSDK.swift | 23 +++- 10 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/SessionManagerTests.swift create mode 100644 REES46/Classes/Sdk/Session/Clock.swift create mode 100644 REES46/Classes/Sdk/Session/SessionManager.swift create mode 100644 REES46/Classes/Sdk/Session/SessionManagerImpl.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index 0c41b056..b5b0d7cf 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ E9049B782B1F56D700BD7FEB /* Inter-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B772B1F56D700BD7FEB /* Inter-Semibold.ttf */; }; E9CF44412B30809C000F53F1 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CF44402B30809C000F53F1 /* SearchViewController.swift */; }; E9EAB8672AF08A43003DC1B5 /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9EAB8662AF08A43003DC1B5 /* Inter.ttf */; }; + EEF5CF9F6430FD8202BB58BE /* SessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */; }; F0605CC99B325F3301237A28 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */; }; F808067588CEF0AA10BCA7D7 /* UserIdentityRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */; }; /* End PBXBuildFile section */ @@ -171,6 +172,7 @@ E9049B772B1F56D700BD7FEB /* Inter-Semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Inter-Semibold.ttf"; sourceTree = ""; }; E9CF44402B30809C000F53F1 /* SearchViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; E9EAB8662AF08A43003DC1B5 /* Inter.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Inter.ttf; sourceTree = ""; }; + F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionManagerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -304,6 +306,7 @@ 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */, 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */, CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */, + F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -744,6 +747,7 @@ 8A5B5B19354E21262D482DEB /* UserIdentityRepositoryIsolationTests.swift in Sources */, CFA0CA171D71D6AB7A4895CC /* KeychainPartitionTests.swift in Sources */, 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */, + EEF5CF9F6430FD8202BB58BE /* SessionManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/SessionManagerTests.swift b/DemoApp/Rees46DemoTests/SessionManagerTests.swift new file mode 100644 index 00000000..25ef3ccc --- /dev/null +++ b/DemoApp/Rees46DemoTests/SessionManagerTests.swift @@ -0,0 +1,124 @@ +// +// SessionManagerTests.swift +// REES46Tests +// +// Covers the rolling session TTL (decision №3 — parity with Android/RN). The clock and id generator +// are injected, so reuse / expiry / refresh are pinned deterministically with no real waiting. +// + +import XCTest +@testable import REES46 + +private final class FakeClock: Clock { + var current: Date + init(_ current: Date) { self.current = current } + func now() -> Date { current } +} + +final class SessionManagerTests: XCTestCase { + + private let base = Date(timeIntervalSince1970: 1_000_000) + private let ttl = SessionManagerImpl.sessionTTL + + private func make( + identity: UserIdentityRepository, + clock: Clock, + generate: @escaping () -> String = { "GENERATED" } + ) -> SessionManagerImpl { + SessionManagerImpl(identity: identity, clock: clock, generate: generate) + } + + func test_generates_and_persists_a_session_when_none_exists() { + let identity = InMemoryUserIdentityRepository() + let clock = FakeClock(base) + let manager = make(identity: identity, clock: clock, generate: { "NEW" }) + + let sid = manager.currentSessionId() + + XCTAssertEqual(sid, "NEW") + XCTAssertEqual(identity.seance, "NEW") + XCTAssertEqual(identity.seanceLastActTime, base) + } + + func test_reuses_the_session_within_the_ttl_window() { + let identity = InMemoryUserIdentityRepository() + identity.seance = "S1" + identity.seanceLastActTime = base.addingTimeInterval(-ttl + 1) // still fresh + let manager = make(identity: identity, clock: FakeClock(base), generate: { "NEW" }) + + XCTAssertEqual(manager.currentSessionId(), "S1") + } + + func test_reuse_refreshes_the_activity_timestamp() { + let identity = InMemoryUserIdentityRepository() + identity.seance = "S1" + identity.seanceLastActTime = base.addingTimeInterval(-3600) // 1h ago + let manager = make(identity: identity, clock: FakeClock(base), generate: { "NEW" }) + + _ = manager.currentSessionId() + + XCTAssertEqual(identity.seanceLastActTime, base, "activity must roll the window forward") + } + + func test_regenerates_after_the_ttl_expires() { + let identity = InMemoryUserIdentityRepository() + identity.seance = "S1" + identity.seanceLastActTime = base.addingTimeInterval(-ttl - 1) // just lapsed + let manager = make(identity: identity, clock: FakeClock(base), generate: { "NEW" }) + + let sid = manager.currentSessionId() + + XCTAssertEqual(sid, "NEW") + XCTAssertEqual(identity.seance, "NEW") + XCTAssertEqual(identity.seanceLastActTime, base) + } + + func test_regenerates_exactly_at_the_ttl_boundary() { + let identity = InMemoryUserIdentityRepository() + identity.seance = "S1" + identity.seanceLastActTime = base.addingTimeInterval(-ttl) // exactly TTL → not fresh + let manager = make(identity: identity, clock: FakeClock(base), generate: { "NEW" }) + + XCTAssertEqual(manager.currentSessionId(), "NEW") + } + + func test_regenerates_when_there_is_no_timestamp() { + let identity = InMemoryUserIdentityRepository() + identity.seance = "S1" + identity.seanceLastActTime = nil + let manager = make(identity: identity, clock: FakeClock(base), generate: { "NEW" }) + + XCTAssertEqual(manager.currentSessionId(), "NEW") + } + + func test_regenerates_when_the_stored_session_is_empty() { + let identity = InMemoryUserIdentityRepository() + identity.seance = "" + identity.seanceLastActTime = base + let manager = make(identity: identity, clock: FakeClock(base), generate: { "NEW" }) + + XCTAssertEqual(manager.currentSessionId(), "NEW") + } + + func test_two_shops_keep_independent_sessions() { + let a = InMemoryUserIdentityRepository() + let b = InMemoryUserIdentityRepository() + let clock = FakeClock(base) + let managerA = make(identity: a, clock: clock, generate: { "A-SID" }) + let managerB = make(identity: b, clock: clock, generate: { "B-SID" }) + + XCTAssertEqual(managerA.currentSessionId(), "A-SID") + XCTAssertEqual(managerB.currentSessionId(), "B-SID") + XCTAssertEqual(a.seance, "A-SID") + XCTAssertEqual(b.seance, "B-SID") + } + + func test_generated_id_is_ten_alphanumeric_characters() { + let allowed = CharacterSet(charactersIn: "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + for _ in 0..<50 { + let sid = SessionManagerImpl.generateSessionId() + XCTAssertEqual(sid.count, 10) + XCTAssertTrue(sid.unicodeScalars.allSatisfy { allowed.contains($0) }, "unexpected char in \(sid)") + } + } +} diff --git a/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift b/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift index 7c27e941..0eea29d5 100644 --- a/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift +++ b/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift @@ -13,9 +13,10 @@ import XCTest final class InMemoryUserIdentityRepository: UserIdentityRepository { var deviceId: String? var seance: String? + var seanceLastActTime: Date? var advertisingId: String? func removeDeviceId() { deviceId = nil } - func clearIdentity() { deviceId = nil; seance = nil } + func clearIdentity() { deviceId = nil; seance = nil; seanceLastActTime = nil } } final class UserIdentityRepositoryTests: XCTestCase { @@ -59,6 +60,17 @@ final class UserIdentityRepositoryTests: XCTestCase { XCTAssertEqual(repo.seance, "seance-1") } + func test_seance_last_act_time_round_trips_and_is_cleared_with_identity() { + let repo = UserIdentityRepositoryImpl(store: freshSuite()) + let timestamp = Date(timeIntervalSince1970: 1_234_567) + + repo.seanceLastActTime = timestamp + XCTAssertEqual(repo.seanceLastActTime?.timeIntervalSince1970, timestamp.timeIntervalSince1970) + + repo.clearIdentity() + XCTAssertNil(repo.seanceLastActTime) + } + func test_clearIdentity_clears_device_id_and_seance_but_not_advertising_id() { let repo = UserIdentityRepositoryImpl(store: freshSuite()) repo.deviceId = "did-1" diff --git a/REES46/Classes/Sdk/Session/Clock.swift b/REES46/Classes/Sdk/Session/Clock.swift new file mode 100644 index 00000000..c4077f46 --- /dev/null +++ b/REES46/Classes/Sdk/Session/Clock.swift @@ -0,0 +1,20 @@ +// +// Clock.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/// A source of the current time, injectable so time-dependent logic (the session TTL) is testable +/// without waiting real seconds. +protocol Clock { + func now() -> Date +} + +/// The production clock — wall-clock time. +struct SystemClock: Clock { + func now() -> Date { Date() } +} diff --git a/REES46/Classes/Sdk/Session/SessionManager.swift b/REES46/Classes/Sdk/Session/SessionManager.swift new file mode 100644 index 00000000..deccb6d2 --- /dev/null +++ b/REES46/Classes/Sdk/Session/SessionManager.swift @@ -0,0 +1,25 @@ +// +// SessionManager.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Owns the shop's session id (`seance`/`sid`) with a rolling time-to-live. + + Aligns iOS with Android and React Native: the session is client-generated, persisted per shop, and + reused across cold starts while it is still fresh (last activity within the TTL). After the window + lapses a new session begins. This replaces the old iOS behaviour of a fresh `UUID` per process / + adopting the server-assigned seance, so session-scoped analytics are consistent across platforms. + */ +protocol SessionManager: AnyObject { + + /// The session id to use now: the persisted one when still inside the TTL window (its activity + /// timestamp is refreshed), otherwise a freshly generated one that is persisted. Idempotent within + /// a window — repeated calls return the same id. + func currentSessionId() -> String +} diff --git a/REES46/Classes/Sdk/Session/SessionManagerImpl.swift b/REES46/Classes/Sdk/Session/SessionManagerImpl.swift new file mode 100644 index 00000000..fd7af7a1 --- /dev/null +++ b/REES46/Classes/Sdk/Session/SessionManagerImpl.swift @@ -0,0 +1,60 @@ +// +// SessionManagerImpl.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Default [SessionManager]: reads/writes the session through the per-shop [UserIdentityRepository], so + the session is partitioned per shop for free. The TTL matches Android/RN (2 hours), and the id is a + 10-character alphanumeric like React Native's `generateSid`. + + The clock and the id generator are injected so the TTL and regeneration are deterministic in tests. + */ +final class SessionManagerImpl: SessionManager { + + /// Rolling session window. Matches Android `SESSION_CODE_EXPIRE` (2 * 3600 * 1000 ms) and RN + /// `SESSION_CODE_EXPIRE` (120 minutes). + static let sessionTTL: TimeInterval = 2 * 60 * 60 + + private let identity: UserIdentityRepository + private let clock: Clock + private let generate: () -> String + + init( + identity: UserIdentityRepository, + clock: Clock = SystemClock(), + generate: @escaping () -> String = SessionManagerImpl.generateSessionId + ) { + self.identity = identity + self.clock = clock + self.generate = generate + } + + func currentSessionId() -> String { + let now = clock.now() + + if let sid = identity.seance, !sid.isEmpty, + let lastAct = identity.seanceLastActTime, + now.timeIntervalSince(lastAct) < Self.sessionTTL { + identity.seanceLastActTime = now // rolling: activity extends the window + return sid + } + + let sid = generate() + identity.seance = sid + identity.seanceLastActTime = now + return sid + } + + /// A 10-character alphanumeric session id (mirror of RN `generateSid`). + static func generateSessionId() -> String { + let source = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + var generator = SystemRandomNumberGenerator() + return String((0..<10).map { _ in source[Int.random(in: 0.. Void)? = nil ) { self.shopId = shopId @@ -120,13 +125,17 @@ class SimplePersonalizationSDK: PersonalizationSDK { // R2: identity/session live in this shop's partition. The default repository migrates the // pre-partition identity in once (existing installs keep their did); an injected repository // (tests) is used verbatim. - self.userIdentity = userIdentity ?? UserIdentityRepositoryImpl(shopId: shopId, storageKey: storageKey) + let identity = userIdentity ?? UserIdentityRepositoryImpl(shopId: shopId, storageKey: storageKey) + self.userIdentity = identity // R2: the keychain init-secret backup is keyed per shop, adopting the legacy item once so a // reinstall keeps this shop's did. Injected (tests) is used verbatim. let keychainService = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier ?? "com.rees46.sdk" self.keychainStore = keychainStore ?? KeychainInitStoreImpl(shopId: shopId, service: keychainService) + // Session (aligned with Android/RN): a rolling 2h client session over the per-shop partition. + self.sessionManager = sessionManager ?? SessionManagerImpl(identity: identity) + self.autoSendPushToken = autoSendPushToken self.parentViewController = parentViewController self.enableAutoPopupPresentation = enableAutoPopupPresentation @@ -141,8 +150,9 @@ class SimplePersonalizationSDK: PersonalizationSDK { self.needReInitialization = needReInitialization self.sendAdvertisingId = sendAdvertisingId - // Generate seance and segment - userSeance = UUID().uuidString + // Resolve the session (reuse the persisted one within the 2h window, else start a new one) + // and pick an A/B segment. + userSeance = self.sessionManager.currentSessionId() segment = ["A", "B"].randomElement() ?? "A" // Fetch user session (permanent user Id) @@ -154,7 +164,8 @@ class SimplePersonalizationSDK: PersonalizationSDK { switch initResult { case .success(let response): self.userInfo = response - self.userSeance = response.seance + // Session is client-owned (see SessionManager) — the server-assigned seance is + // intentionally not adopted, matching Android/RN. self.deviceId = response.deviceId if let popup = response.popup { @@ -1235,7 +1246,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { if keychainDid.isEmpty || needReInitialization { persistDeviceIdIfAbsent(resultResponse.deviceId) - userIdentity.seance = resultResponse.seance + // Session is client-owned — do not overwrite it with the server-assigned seance. sleep(1) completion(.success(resultResponse)) } else { @@ -1392,7 +1403,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { func storeSuccessInit(result: InitResponse) { persistDeviceIdIfAbsent(result.deviceId) - userIdentity.seance = result.seance + // Session is client-owned (see SessionManager) — the server seance is not persisted here. } private static let deviceIdPersistLock = NSObject() From 2b933f0b95ec1179bc086db2ae112562835887f5 Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 22:05:45 +0400 Subject: [PATCH 05/10] feat(sdk): partition stories UI state per shop_id and pin the non-breaking upgrade Route viewed slides, downloaded media, cart and favourites through a per-shop LocalStateRepository instead of the shared UserDefaults.standard, so two shops in one app no longer share or overwrite each other's stories state. shopId is threaded through StoryContent -> Story -> Slide (slide ids are not unique across shops), and the reset* methods delegate to the repository, so constructing one shop no longer wipes another shop's viewed state. Add an SDK-level upgrade test that seeds a pre-partition install's identity in the real .standard domain and keychain and drives it through the production UserIdentityRepositoryImpl(shopId:) / KeychainInitStoreImpl(shopId:service:) constructors, proving an existing install keeps its did/seance/IDFA/keychain blob (no silent re-registration) and a second shop never clones it. --- DemoApp/REES46.xcodeproj/project.pbxproj | 8 + .../LocalStateRepositoryTests.swift | 92 +++++++++ .../UpgradeMigrationTests.swift | 174 ++++++++++++++++++ REES46/Classes/Model/Stories.swift | 39 ++-- .../Sdk/Storage/LocalStateRepository.swift | 42 +++++ .../Storage/LocalStateRepositoryImpl.swift | 75 ++++++++ .../PersonalizationSDK+LocalState.swift | 19 ++ .../Sdk/impl/SimplePersonalizationSDK.swift | 11 +- .../PersonalizationSDK.protocol.swift | 76 +------- REES46/Classes/Stories/StoriesView.swift | 15 +- .../RecommendationsWidgetView.swift | 21 +-- ...earchResultsViewController.extension.swift | 16 +- .../StoryViewController.swift | 31 ++-- 13 files changed, 487 insertions(+), 132 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/LocalStateRepositoryTests.swift create mode 100644 DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift create mode 100644 REES46/Classes/Sdk/Storage/LocalStateRepository.swift create mode 100644 REES46/Classes/Sdk/Storage/LocalStateRepositoryImpl.swift create mode 100644 REES46/Classes/Sdk/Storage/PersonalizationSDK+LocalState.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index b5b0d7cf..e4552078 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0169B35A9F893F6D8D22820A /* Pods_REES46Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */; }; 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */; }; 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */; }; + 593033DF60A44FEA08C3FAAF /* LocalStateRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */; }; 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 607FACD81AFB9204008FA782 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* MainViewController.swift */; }; 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; @@ -25,6 +26,7 @@ 75E237932C334AD200119197 /* DeviceIdTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E237912C334AD200119197 /* DeviceIdTest.swift */; }; 7FEFCE64D31526E14ADA2679 /* FakeSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */; }; 8A5B5B19354E21262D482DEB /* UserIdentityRepositoryIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */; }; + 92F69F58F5C797C0D5C31DBE /* UpgradeMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */; }; 9A7A95DF25BC40E900FFA40A /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31DE25A1C6F700D9D1AA /* UserNotifications.framework */; }; 9A7A95E025BC40E900FFA40A /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31E025A1C6F700D9D1AA /* UserNotificationsUI.framework */; }; 9A7A95E325BC40E900FFA40A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7A95E225BC40E900FFA40A /* NotificationViewController.swift */; }; @@ -107,6 +109,7 @@ 35748965E9FE2297E49752C5 /* Pods-REES46Demo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Demo.release.xcconfig"; path = "Target Support Files/Pods-REES46Demo/Pods-REES46Demo.release.xcconfig"; sourceTree = ""; }; 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoragePartitionTests.swift; sourceTree = ""; }; 53578CC38C4B3A4AA9973CCC /* REES46.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = REES46.podspec; path = ../REES46.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; + 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalStateRepositoryTests.swift; sourceTree = ""; }; 5AE5CCEEB00A5EF1E5E39AFF /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeychainPartitionTests.swift; sourceTree = ""; }; 607FACD01AFB9204008FA782 /* REES46Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = REES46Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -157,6 +160,7 @@ AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FakeSDK.swift; sourceTree = ""; }; B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InstanceResolverTests.swift; sourceTree = ""; }; B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UpgradeMigrationTests.swift; sourceTree = ""; }; CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeychainInitStoreTests.swift; sourceTree = ""; }; D005D80C2DB2CDE000841D78 /* MockGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenerator.swift; sourceTree = ""; }; D0763E592DB8F5170013E367 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; @@ -307,6 +311,8 @@ 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */, CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */, F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */, + 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */, + B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -748,6 +754,8 @@ CFA0CA171D71D6AB7A4895CC /* KeychainPartitionTests.swift in Sources */, 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */, EEF5CF9F6430FD8202BB58BE /* SessionManagerTests.swift in Sources */, + 593033DF60A44FEA08C3FAAF /* LocalStateRepositoryTests.swift in Sources */, + 92F69F58F5C797C0D5C31DBE /* UpgradeMigrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/LocalStateRepositoryTests.swift b/DemoApp/Rees46DemoTests/LocalStateRepositoryTests.swift new file mode 100644 index 00000000..c0b290a0 --- /dev/null +++ b/DemoApp/Rees46DemoTests/LocalStateRepositoryTests.swift @@ -0,0 +1,92 @@ +// +// LocalStateRepositoryTests.swift +// REES46Tests +// +// Covers the per-shop stories UI state store (R2 / iOS-8): domain round-trips, clear-by-domain, and +// two-shop isolation. Uses isolated UserDefaults suites so real domains are never touched. +// + +import XCTest +@testable import REES46 + +final class LocalStateRepositoryTests: XCTestCase { + + private var suiteNames: [String] = [] + + private func freshRepo() -> LocalStateRepositoryImpl { + let name = "test-\(UUID().uuidString)" + suiteNames.append(name) + return LocalStateRepositoryImpl(store: UserDefaults(suiteName: name)!) + } + + override func tearDown() { + for name in suiteNames { + UserDefaults.standard.removePersistentDomain(forName: name) + } + suiteNames.removeAll() + super.tearDown() + } + + func test_each_domain_round_trips_by_id() { + let repo = freshRepo() + + repo.setViewedSlides(["s1", "s2"], storyId: "10") + repo.setDownloadedMedia(["m1"], slideId: "20") + repo.setCartProducts(["p1"], productId: "30") + repo.setFavoriteProducts(["p2"], productId: "40") + + XCTAssertEqual(repo.viewedSlides(storyId: "10"), ["s1", "s2"]) + XCTAssertEqual(repo.downloadedMedia(slideId: "20"), ["m1"]) + XCTAssertEqual(repo.cartProducts(productId: "30"), ["p1"]) + XCTAssertEqual(repo.favoriteProducts(productId: "40"), ["p2"]) + } + + func test_missing_keys_return_empty() { + let repo = freshRepo() + XCTAssertEqual(repo.viewedSlides(storyId: "nope"), []) + XCTAssertEqual(repo.cartProducts(productId: "nope"), []) + } + + func test_clear_is_scoped_to_its_own_domain() { + let repo = freshRepo() + repo.setViewedSlides(["s1"], storyId: "1") + repo.setDownloadedMedia(["m1"], slideId: "1") + repo.setCartProducts(["p1"], productId: "1") + repo.setFavoriteProducts(["f1"], productId: "1") + + repo.clearViewedSlides() + + XCTAssertEqual(repo.viewedSlides(storyId: "1"), []) + XCTAssertEqual(repo.downloadedMedia(slideId: "1"), ["m1"], "clearing viewed must not touch downloaded") + XCTAssertEqual(repo.cartProducts(productId: "1"), ["p1"]) + XCTAssertEqual(repo.favoriteProducts(productId: "1"), ["f1"]) + } + + func test_clear_removes_every_key_in_the_domain() { + let repo = freshRepo() + repo.setCartProducts(["a"], productId: "1") + repo.setCartProducts(["b"], productId: "2") + + repo.clearCartProducts() + + XCTAssertEqual(repo.cartProducts(productId: "1"), []) + XCTAssertEqual(repo.cartProducts(productId: "2"), []) + } + + func test_two_shops_do_not_share_state() { + let shopA = freshRepo() + let shopB = freshRepo() + + shopA.setViewedSlides(["a"], storyId: "1") + shopA.setCartProducts(["ca"], productId: "1") + shopB.setViewedSlides(["b"], storyId: "1") + + XCTAssertEqual(shopA.viewedSlides(storyId: "1"), ["a"]) + XCTAssertEqual(shopB.viewedSlides(storyId: "1"), ["b"]) + XCTAssertEqual(shopB.cartProducts(productId: "1"), [], "shop B must not see shop A's cart") + + shopA.clearViewedSlides() + XCTAssertEqual(shopA.viewedSlides(storyId: "1"), []) + XCTAssertEqual(shopB.viewedSlides(storyId: "1"), ["b"], "clearing shop A must not touch shop B") + } +} diff --git a/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift b/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift new file mode 100644 index 00000000..67c6c80e --- /dev/null +++ b/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift @@ -0,0 +1,174 @@ +// +// UpgradeMigrationTests.swift +// REES46Tests +// +// iOS-20 — the non-breaking upgrade guarantee at the SDK's real construction path. +// +// StorageMigrationTests unit-tests the pure `migrateLegacyIfNeeded(into:legacy:)` with an *injected* +// legacy suite, and KeychainInitStoreTests uses a stand-in service — both deliberately bypass the +// production wiring. This test seeds a pre-partition install's real state (did/seance/IDFA in +// `.standard` plus a legacy keychain blob) and drives it through the exact constructors +// `SimplePersonalizationSDK.init` uses — `UserIdentityRepositoryImpl(shopId:)` (hardcoded +// `legacy: .standard`, real `personalization_sdk_` suite naming) and +// `KeychainInitStoreImpl(shopId:service:)`. It pins the two guarantees an upgrade must keep: +// 1. the existing install's identity is inherited by the default shop — `repo.deviceId` is the +// legacy did, which is exactly what the SDK reads into `deviceId` and sends to `/init`, so no +// silent re-registration happens; +// 2. a second shop never clones that identity. +// +// `.standard` is snapshotted and restored around each test so the host app's real identity is never +// disturbed; partition suites and keychain services are unique per test and wiped in tearDown. +// + +import XCTest +import Security +@testable import REES46 + +final class UpgradeMigrationTests: XCTestCase { + + private let standardKeys = [ + StoragePartition.deviceIdKey, + StoragePartition.seanceKey, + StoragePartition.idfaKey, + StoragePartition.legacyConsumedFlag + ] + private var standardSnapshot: [String: Any] = [:] + private var partitionShops: [String] = [] + private var keychainServices: [String] = [] + + override func setUp() { + super.setUp() + // Snapshot then clear the legacy `.standard` keys so each test starts from a clean + // pre-partition slate without disturbing the host app's real identity (restored in tearDown). + standardSnapshot = [:] + for key in standardKeys { + if let value = UserDefaults.standard.object(forKey: key) { + standardSnapshot[key] = value + } + UserDefaults.standard.removeObject(forKey: key) + } + } + + override func tearDown() { + for shop in partitionShops { + UserDefaults.standard.removePersistentDomain(forName: StoragePartition.suiteName(for: shop)) + } + partitionShops.removeAll() + for service in keychainServices { + SecItemDelete([ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service + ] as CFDictionary) + } + keychainServices.removeAll() + for key in standardKeys { + if let value = standardSnapshot[key] { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + standardSnapshot = [:] + super.tearDown() + } + + /// A random, filename-safe shop id whose partition suite is removed in tearDown. + private func freshShop() -> String { + let shop = "upgrade-\(UUID().uuidString)" + partitionShops.append(shop) + return shop + } + + private func uniqueService() -> String { + let service = "test-keychain-\(UUID().uuidString)" + keychainServices.append(service) + return service + } + + private func blob(_ s: String) -> Data { Data(s.utf8) } + + /// Seed a pre-partition install's identity in the real `.standard` domain. + private func seedLegacyStandard(did: String, seance: String, idfa: String) { + UserDefaults.standard.set(did, forKey: StoragePartition.deviceIdKey) + UserDefaults.standard.set(seance, forKey: StoragePartition.seanceKey) + UserDefaults.standard.set(idfa, forKey: StoragePartition.idfaKey) + } + + // MARK: - UserDefaults identity + + func test_existing_install_keeps_identity_through_the_real_repository_constructor() { + seedLegacyStandard(did: "did-upgrade", seance: "seance-upgrade", idfa: "idfa-upgrade") + + // The exact expression SimplePersonalizationSDK.init uses for the default shop. + let repo = UserIdentityRepositoryImpl(shopId: freshShop()) + + // `repo.deviceId` is what the SDK reads into `deviceId` at init and sends to `/init`; being the + // legacy did, no new registration is triggered — the core non-breaking guarantee. + XCTAssertEqual(repo.deviceId, "did-upgrade") + XCTAssertEqual(repo.seance, "seance-upgrade") + XCTAssertEqual(repo.advertisingId, "idfa-upgrade") + XCTAssertTrue(UserDefaults.standard.bool(forKey: StoragePartition.legacyConsumedFlag), + "the one-time adoption must mark the legacy source consumed") + } + + func test_second_shop_after_upgrade_gets_a_fresh_identity() { + seedLegacyStandard(did: "did-upgrade", seance: "seance-upgrade", idfa: "idfa-upgrade") + + let first = UserIdentityRepositoryImpl(shopId: freshShop()) + let second = UserIdentityRepositoryImpl(shopId: freshShop()) + + XCTAssertEqual(first.deviceId, "did-upgrade", "the default shop inherits the pre-partition did") + XCTAssertNil(second.deviceId, "a second shop must not clone the upgraded identity") + } + + func test_a_fresh_install_starts_without_an_inherited_identity() { + // No legacy `.standard` seed — a brand-new install has nothing to adopt. + let repo = UserIdentityRepositoryImpl(shopId: freshShop()) + + XCTAssertNil(repo.deviceId) + XCTAssertFalse(UserDefaults.standard.bool(forKey: StoragePartition.legacyConsumedFlag), + "nothing to consume on a fresh install") + } + + // MARK: - Keychain + + func test_keychain_did_backup_survives_upgrade_for_the_default_shop() { + let service = uniqueService() + // A pre-partition install stored its only keychain item at the bundle-id (legacy) account. + try? InitService.upsertKeychainDidToken( + blob("keychain-did"), + identifier: KeychainPartition.legacyAccount(service: service), + instanceKeychainService: service + ) + + // The exact expression SimplePersonalizationSDK.init uses for the keychain backup. + let store = KeychainInitStoreImpl(shopId: freshShop(), service: service) + XCTAssertEqual(store.readInitData(), blob("keychain-did")) + + // Consumed by the first shop, so a reinstall under a second shop can't clone the identity. + let second = KeychainInitStoreImpl(shopId: freshShop(), service: service) + XCTAssertNil(second.readInitData()) + } + + // MARK: - Combined + + func test_full_identity_did_seance_idfa_and_keychain_survive_together() { + seedLegacyStandard(did: "did-full", seance: "seance-full", idfa: "idfa-full") + let service = uniqueService() + try? InitService.upsertKeychainDidToken( + blob("did-full"), + identifier: KeychainPartition.legacyAccount(service: service), + instanceKeychainService: service + ) + let shop = freshShop() + + // Construct identity + keychain the way SimplePersonalizationSDK.init does for one shop. + let repo = UserIdentityRepositoryImpl(shopId: shop) + let keychain = KeychainInitStoreImpl(shopId: shop, service: service) + + XCTAssertEqual(repo.deviceId, "did-full") + XCTAssertEqual(repo.seance, "seance-full") + XCTAssertEqual(repo.advertisingId, "idfa-full") + XCTAssertEqual(keychain.readInitData(), blob("did-full")) + } +} diff --git a/REES46/Classes/Model/Stories.swift b/REES46/Classes/Model/Stories.swift index 8101e2d2..1f2cb0d9 100644 --- a/REES46/Classes/Model/Stories.swift +++ b/REES46/Classes/Model/Stories.swift @@ -8,14 +8,14 @@ public class StoryContent { let settings: StoriesSettings let stories: [Story] - public init(json: [String: Any]) { + public init(json: [String: Any], shopId: String = "") { self.id = json["id"] as? String ?? "-1" self.ids = json["id"] as? Int ?? -1 let _settings = json["settings"] as? [String: Any] ?? [:] self.settings = StoriesSettings(json: _settings) let _stories = json["stories"] as? [[String: Any]] ?? [] - self.stories = _stories.map({Story(json: $0)}) - + self.stories = _stories.map({ Story(json: $0, shopId: shopId) }) + if let ids = json["id"] as? Int { self.id = String(ids) } @@ -57,8 +57,8 @@ class Story { let name: String let pinned: Bool let slides: [Slide] - - public init(json: [String: Any]) { + + public init(json: [String: Any], shopId: String = "") { self.id = json["id"] as? String ?? "-1" self.ids = json["id"] as? Int ?? -1 self.avatar = json["avatar"] as? String ?? "" @@ -67,8 +67,8 @@ class Story { self.name = json["name"] as? String ?? "" self.pinned = json["pinned"] as? Bool ?? false let _slides = json["slides"] as? [[String: Any]] ?? [] - self.slides = _slides.map({Slide(json: $0)}) - + self.slides = _slides.map({ Slide(json: $0, shopId: shopId) }) + if let ids = json["id"] as? Int { self.id = String(ids) } @@ -92,8 +92,15 @@ class Slide { public let vDownloadManager = VideoDownloadManager.shared var sdkDirectoryName: String = "SDKCacheDirectory" - - public init(json: [String: Any]) { + + /// The shop this slide belongs to, so its downloaded-media state lands in the right partition. + /// Slide ids are not unique across shops, so this state must be per shop. + private let shopId: String + private lazy var localState: LocalStateRepository = + LocalStateRepositoryImpl(store: StoragePartition.store(for: shopId)) + + public init(json: [String: Any], shopId: String = "") { + self.shopId = shopId self.id = json["id"] as? String ?? "-1" self.ids = json["id"] as? Int ?? -1 self.duration = json["duration"] as? Int ?? 10 @@ -231,20 +238,22 @@ class Slide { } private func completionCached(slideWithId: String, actualSlideUrl: String) { + // Storage is per shop (slide ids collide across shops); the notification name stays the raw + // in-process signal. let storySlideMediaId = "cached.slide." + slideWithId - - var slidesDownloadedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storySlideMediaId)) as? [String] ?? [] + + var slidesDownloadedArray = localState.downloadedMedia(slideId: slideWithId) let slideWithIdExists = slidesDownloadedArray.contains(where: { $0.range(of: storySlideMediaId) != nil }) - + if !slideWithIdExists { slidesDownloadedArray.append(storySlideMediaId) - UserDefaults.standard.setValue(slidesDownloadedArray, for: UserDefaults.Key(storySlideMediaId)) + localState.setDownloadedMedia(slidesDownloadedArray, slideId: slideWithId) } - + let userInfo = ["url": actualSlideUrl] as [String: Any] - NotificationCenter.default.post(name:Notification.Name(storySlideMediaId), object: userInfo) + NotificationCenter.default.post(name: Notification.Name(storySlideMediaId), object: userInfo) } } diff --git a/REES46/Classes/Sdk/Storage/LocalStateRepository.swift b/REES46/Classes/Sdk/Storage/LocalStateRepository.swift new file mode 100644 index 00000000..0ad145e9 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/LocalStateRepository.swift @@ -0,0 +1,42 @@ +// +// LocalStateRepository.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Per-shop UI state that the stories block persists: which slides a user has viewed, which slide media + has been downloaded, and which products are in the cart / favourites. + + Multi-instance (R2): this state used to live in `UserDefaults.standard` under composed keys + (`viewed.slide.`, `cached.slide.`, `cart.product.`, `favorites.product.`). + Slide and product ids are not unique across shops (they are small integers), so a shared store let + one shop see another's viewed/cart/favourites/downloaded state. This port scopes it to the shop's + partition. The keys are composed inside the implementation, so call sites deal in ids only. + + Note: the `cached.slide.` **notification names** used for in-process signalling are a separate + concern and are intentionally left un-partitioned — only the persisted state moves here. + */ +protocol LocalStateRepository: AnyObject { + + func viewedSlides(storyId: String) -> [String] + func setViewedSlides(_ slides: [String], storyId: String) + + func downloadedMedia(slideId: String) -> [String] + func setDownloadedMedia(_ media: [String], slideId: String) + + func cartProducts(productId: String) -> [String] + func setCartProducts(_ products: [String], productId: String) + + func favoriteProducts(productId: String) -> [String] + func setFavoriteProducts(_ products: [String], productId: String) + + func clearViewedSlides() + func clearDownloadedMedia() + func clearCartProducts() + func clearFavoriteProducts() +} diff --git a/REES46/Classes/Sdk/Storage/LocalStateRepositoryImpl.swift b/REES46/Classes/Sdk/Storage/LocalStateRepositoryImpl.swift new file mode 100644 index 00000000..043a08a1 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/LocalStateRepositoryImpl.swift @@ -0,0 +1,75 @@ +// +// LocalStateRepositoryImpl.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + `UserDefaults`-backed [LocalStateRepository] over the shop's partition suite, so viewed / downloaded + / cart / favourites state is isolated per shop. Preserves the original key scheme (the values are + arrays of ids), so migrated behaviour is byte-for-byte the same — only the domain changes. + */ +final class LocalStateRepositoryImpl: LocalStateRepository { + + private static let viewedSlidePrefix = "viewed.slide." + private static let cachedSlidePrefix = "cached.slide." + private static let cartPrefix = "cart.product." + private static let favoritesPrefix = "favorites.product." + + private let store: UserDefaults + + init(store: UserDefaults) { + self.store = store + } + + func viewedSlides(storyId: String) -> [String] { + array(Self.viewedSlidePrefix + storyId) + } + + func setViewedSlides(_ slides: [String], storyId: String) { + store.set(slides, forKey: Self.viewedSlidePrefix + storyId) + } + + func downloadedMedia(slideId: String) -> [String] { + array(Self.cachedSlidePrefix + slideId) + } + + func setDownloadedMedia(_ media: [String], slideId: String) { + store.set(media, forKey: Self.cachedSlidePrefix + slideId) + } + + func cartProducts(productId: String) -> [String] { + array(Self.cartPrefix + productId) + } + + func setCartProducts(_ products: [String], productId: String) { + store.set(products, forKey: Self.cartPrefix + productId) + } + + func favoriteProducts(productId: String) -> [String] { + array(Self.favoritesPrefix + productId) + } + + func setFavoriteProducts(_ products: [String], productId: String) { + store.set(products, forKey: Self.favoritesPrefix + productId) + } + + func clearViewedSlides() { removeAll(withPrefix: Self.viewedSlidePrefix) } + func clearDownloadedMedia() { removeAll(withPrefix: Self.cachedSlidePrefix) } + func clearCartProducts() { removeAll(withPrefix: Self.cartPrefix) } + func clearFavoriteProducts() { removeAll(withPrefix: Self.favoritesPrefix) } + + private func array(_ key: String) -> [String] { + store.stringArray(forKey: key) ?? [] + } + + private func removeAll(withPrefix prefix: String) { + for key in store.dictionaryRepresentation().keys where key.hasPrefix(prefix) { + store.removeObject(forKey: key) + } + } +} diff --git a/REES46/Classes/Sdk/Storage/PersonalizationSDK+LocalState.swift b/REES46/Classes/Sdk/Storage/PersonalizationSDK+LocalState.swift new file mode 100644 index 00000000..9faab4fa --- /dev/null +++ b/REES46/Classes/Sdk/Storage/PersonalizationSDK+LocalState.swift @@ -0,0 +1,19 @@ +// +// PersonalizationSDK+LocalState.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +internal extension PersonalizationSDK { + + /// This shop's stories UI state store. Internal bridge over the concrete SDK, so the stories views + /// reach the per-shop state through their existing `sdk` reference without widening the public + /// `PersonalizationSDK` protocol. `nil` only if `sdk` is not the SDK's own implementation. + var localState: LocalStateRepository? { + (self as? SimplePersonalizationSDK)?.localStateStore + } +} diff --git a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift index 23ca060b..c84705f0 100644 --- a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift +++ b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift @@ -44,6 +44,10 @@ class SimplePersonalizationSDK: PersonalizationSDK { /// Owns the session id with a rolling 2h TTL, persisted per shop and reused across cold starts — /// aligned with Android/RN. See [SessionManager]. let sessionManager: SessionManager + + /// Per-shop stories UI state (viewed slides / downloaded media / cart / favourites). Reached from + /// the stories views through the `PersonalizationSDK.localState` bridge. See [LocalStateRepository]. + let localStateStore: LocalStateRepository let sdkBundleId = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier let appBundleId = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier @@ -118,6 +122,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { userIdentity: UserIdentityRepository? = nil, keychainStore: KeychainInitStore? = nil, sessionManager: SessionManager? = nil, + localState: LocalStateRepository? = nil, completion: ((SdkError?) -> Void)? = nil ) { self.shopId = shopId @@ -128,6 +133,10 @@ class SimplePersonalizationSDK: PersonalizationSDK { let identity = userIdentity ?? UserIdentityRepositoryImpl(shopId: shopId, storageKey: storageKey) self.userIdentity = identity + // R2: stories UI state (viewed/downloaded/cart/favourites) shares this shop's partition suite. + self.localStateStore = localState + ?? LocalStateRepositoryImpl(store: StoragePartition.store(for: shopId, storageKey: storageKey)) + // R2: the keychain init-secret backup is keyed per shop, adopting the legacy item once so a // reinstall keeps this shop's did. Injected (tests) is used verbatim. let keychainService = Bundle(for: SimplePersonalizationSDK.self).bundleIdentifier ?? "com.rees46.sdk" @@ -1392,7 +1401,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { self.getRequest(path: path, params: params, false) { result in switch result { case let .success(successResult): - let res = StoryContent(json: successResult) + let res = StoryContent(json: successResult, shopId: self.shopId) completion(.success(res)) case let .failure(error): completion(.failure(error)) diff --git a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift index 6d72aa07..9668b366 100644 --- a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift +++ b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift @@ -295,81 +295,23 @@ public extension PersonalizationSDK { deleteUserCredentials() } + // R2: these caches are partitioned per shop — delegate to the shop's [LocalStateRepository] via + // the internal `localState` bridge instead of scanning the shared `.standard` domain. func resetSdkCache() { - let included_prefixes = ["viewed.slide."] - let dict = UserDefaults.standard.dictionaryRepresentation() - let keys = dict.keys.filter { key in - for prefix in included_prefixes { - if key.hasPrefix(prefix) { - return true - } - } - return false - } - for key in keys { - if dict[key] != nil { - UserDefaults.standard.removeObject(forKey: key) - } - } - UserDefaults.standard.synchronize() + localState?.clearViewedSlides() resetDownloadedStoriesStates() } - + func resetDownloadedStoriesStates() { - let included_prefixes = ["cached.slide."] - let dict = UserDefaults.standard.dictionaryRepresentation() - let keys = dict.keys.filter { key in - for prefix in included_prefixes { - if key.hasPrefix(prefix) { - return true - } - } - return false - } - for key in keys { - if dict[key] != nil { - UserDefaults.standard.removeObject(forKey: key) - } - } - UserDefaults.standard.synchronize() + localState?.clearDownloadedMedia() } - + func resetCartProductStates() { - let included_prefixes = ["cart.product."] - let dict = UserDefaults.standard.dictionaryRepresentation() - let keys = dict.keys.filter { key in - for prefix in included_prefixes { - if key.hasPrefix(prefix) { - return true - } - } - return false - } - for key in keys { - if dict[key] != nil { - UserDefaults.standard.removeObject(forKey: key) - } - } - UserDefaults.standard.synchronize() + localState?.clearCartProducts() } - + func resetFavoritesProductStates() { - let included_prefixes = ["favorites.product."] - let dict = UserDefaults.standard.dictionaryRepresentation() - let keys = dict.keys.filter { key in - for prefix in included_prefixes { - if key.hasPrefix(prefix) { - return true - } - } - return false - } - for key in keys { - if dict[key] != nil { - UserDefaults.standard.removeObject(forKey: key) - } - } - UserDefaults.standard.synchronize() + localState?.clearFavoriteProducts() } } diff --git a/REES46/Classes/Stories/StoriesView.swift b/REES46/Classes/Stories/StoriesView.swift index ebee5fb5..88619ce7 100644 --- a/REES46/Classes/Stories/StoriesView.swift +++ b/REES46/Classes/Stories/StoriesView.swift @@ -216,15 +216,14 @@ extension StoriesView: UICollectionViewDelegate, UICollectionViewDataSource, UIC if let currentStory = stories?[indexPath.row] { let storyId = currentStory.id - let storyName = "viewed.slide." + storyId - + var allStoriesMainArray: [String] = [] for (index, _) in currentStory.slides.enumerated() { //print("Story has \(index + 1): \(currentStory.slides[(index)].id)") allStoriesMainArray.append(currentStory.slides[(index)].id) } - - let viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + + let viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] if (viewedSlidesStoriesCachedArray.count == allStoriesMainArray.count) { cell.configureCell(settings: settings, viewed: currentStory.viewed, viewedLocalKey: true, storyId: currentStory.id) cell.configure(story: currentStory) @@ -280,15 +279,13 @@ extension StoriesView: UICollectionViewDelegate, UICollectionViewDataSource, UIC storyVC.sdkLinkDelegate = self storyVC.sdk = sdk storyVC.stories = stories ?? [] - - let sId = "viewed.slide." + story.id - + var allSlidesIDs: [String] = [] for slide in story.slides { allSlidesIDs.append(slide.id) } - - let viewedSlidesCachedIDs: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(sId)) as? [String] ?? [] + + let viewedSlidesCachedIDs: [String] = sdk?.localState?.viewedSlides(storyId: story.id) ?? [] if let lastViewedSlideID = viewedSlidesCachedIDs.last, let defaultIndex = allSlidesIDs.firstIndex(of: lastViewedSlideID) { diff --git a/REES46/Classes/Stories/StoryViewController/RecommendationsWidget/RecommendationsWidgetView.swift b/REES46/Classes/Stories/StoryViewController/RecommendationsWidget/RecommendationsWidgetView.swift index 4545d2eb..acb27290 100755 --- a/REES46/Classes/Stories/StoryViewController/RecommendationsWidget/RecommendationsWidgetView.swift +++ b/REES46/Classes/Stories/StoryViewController/RecommendationsWidget/RecommendationsWidgetView.swift @@ -131,8 +131,7 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate } let pId = cells[indexPath.row].id - let productCartId = "cart.product." + pId - let cartItemsCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(productCartId)) as? [String] ?? [] + let cartItemsCachedArray: [String] = sdk?.localState?.cartProducts(productId: pId) ?? [] let itemIdExistInCart = cartItemsCachedArray.contains(where: { $0.range(of: pId) != nil }) @@ -156,8 +155,7 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate cell.recommendationsCartButton.backgroundColor = bgColor } - let productFavoritesId = "favorites.product." + pId - let favoritesItemsCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(productFavoritesId)) as? [String] ?? [] + let favoritesItemsCachedArray: [String] = sdk?.localState?.favoriteProducts(productId: pId) ?? [] let itemIdExistInFavorites = favoritesItemsCachedArray.contains(where: { $0.range(of: pId) != nil }) @@ -170,15 +168,14 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate if let indexPath = indexPath(for: cell) { let selectedProductForCartFromWidget = cells[indexPath.row] - let productInCartId = "cart.product." + selectedProductForCartFromWidget.id - var viewedSlidesCartCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(productInCartId)) as? [String] ?? [] + var viewedSlidesCartCachedArray: [String] = sdk?.localState?.cartProducts(productId: selectedProductForCartFromWidget.id) ?? [] let viewedCartSlideIdExists = viewedSlidesCartCachedArray.contains(where: { $0.range(of: selectedProductForCartFromWidget.id) != nil }) if !viewedCartSlideIdExists { viewedSlidesCartCachedArray.append(selectedProductForCartFromWidget.id) - UserDefaults.standard.setValue(viewedSlidesCartCachedArray, for: UserDefaults.Key(productInCartId)) + sdk?.localState?.setCartProducts(viewedSlidesCartCachedArray, productId: selectedProductForCartFromWidget.id) cell.recommendationsCartButton.setTitle(SdkConfiguration.recommendations.widgetRemoveFromCartButtonText, for: .normal) sdkAddToCart(productId: selectedProductForCartFromWidget.id) @@ -186,7 +183,7 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate if let index = viewedSlidesCartCachedArray.firstIndex(of: selectedProductForCartFromWidget.id) { viewedSlidesCartCachedArray.remove(at: index) } - UserDefaults.standard.setValue(viewedSlidesCartCachedArray, for: UserDefaults.Key(productInCartId)) + sdk?.localState?.setCartProducts(viewedSlidesCartCachedArray, productId: selectedProductForCartFromWidget.id) cell.recommendationsCartButton.setTitle(SdkConfiguration.recommendations.widgetAddToCartButtonText, for: .normal) sdkRemoveFromCart(productId: selectedProductForCartFromWidget.id) @@ -202,9 +199,7 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate public func didTapWidgetAddToFavoritesButtonInside(cell: RecommendationsWidgetViewCell, position: CGPoint) { if let indexPath = indexPath(for: cell) { let selectedProductForFavoritesFromWidget = cells[indexPath.row] - let productFavoritesId = "favorites.product." + selectedProductForFavoritesFromWidget.id - - var viewedSlidesFavoritesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(productFavoritesId)) as? [String] ?? [] + var viewedSlidesFavoritesCachedArray: [String] = sdk?.localState?.favoriteProducts(productId: selectedProductForFavoritesFromWidget.id) ?? [] let viewedFavoritesSlideIdExists = viewedSlidesFavoritesCachedArray.contains(where: { $0.range(of: selectedProductForFavoritesFromWidget.id) != nil }) @@ -215,7 +210,7 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate #endif if !viewedFavoritesSlideIdExists { viewedSlidesFavoritesCachedArray.append(selectedProductForFavoritesFromWidget.id) - UserDefaults.standard.setValue(viewedSlidesFavoritesCachedArray, for: UserDefaults.Key(productFavoritesId)) + sdk?.localState?.setFavoriteProducts(viewedSlidesFavoritesCachedArray, productId: selectedProductForFavoritesFromWidget.id) sdkAddToFavorites(productId: selectedProductForFavoritesFromWidget.id) @@ -237,7 +232,7 @@ open class RecommendationsWidgetView: UICollectionView, UICollectionViewDelegate if let index = viewedSlidesFavoritesCachedArray.firstIndex(of: selectedProductForFavoritesFromWidget.id) { viewedSlidesFavoritesCachedArray.remove(at: index) } - UserDefaults.standard.setValue(viewedSlidesFavoritesCachedArray, for: UserDefaults.Key(productFavoritesId)) + sdk?.localState?.setFavoriteProducts(viewedSlidesFavoritesCachedArray, productId: selectedProductForFavoritesFromWidget.id) sdkRemoveFromFavorites(productId: selectedProductForFavoritesFromWidget.id) diff --git a/REES46/Classes/Stories/StoryViewController/SearchWidget/Extensions/AllSearchResultsViewController.extension.swift b/REES46/Classes/Stories/StoryViewController/SearchWidget/Extensions/AllSearchResultsViewController.extension.swift index 852d5c87..a6e96bcd 100644 --- a/REES46/Classes/Stories/StoryViewController/SearchWidget/Extensions/AllSearchResultsViewController.extension.swift +++ b/REES46/Classes/Stories/StoryViewController/SearchWidget/Extensions/AllSearchResultsViewController.extension.swift @@ -70,7 +70,7 @@ extension AllSearchResultsViewController: UICollectionViewDataSource, UICollecti private func toggleCartItem(for productId: String, in cell: RecommendationsWidgetViewCell) { print("Click 1") let cartKey = "cart.product.\(productId)" - let cartItems = UserDefaults.standard.stringArray(forKey: cartKey) ?? [] + let cartItems = sdk?.localState?.cartProducts(productId: productId) ?? [] if cartItems.contains(productId) { removeProductFromCart(cartItems, productId: productId, cartKey: cartKey, cell: cell) @@ -82,7 +82,7 @@ extension AllSearchResultsViewController: UICollectionViewDataSource, UICollecti private func toggleFavoritesItem(for productId: String, in cell: RecommendationsWidgetViewCell) { print("Click 2") let favoritesKey = "favorites.product.\(productId)" - let favoritesItems = UserDefaults.standard.stringArray(forKey: favoritesKey) ?? [] + let favoritesItems = sdk?.localState?.favoriteProducts(productId: productId) ?? [] if favoritesItems.contains(productId) { removeProductFromFavorites(favoritesItems, productId: productId, favoritesKey: favoritesKey, cell: cell) @@ -94,7 +94,7 @@ extension AllSearchResultsViewController: UICollectionViewDataSource, UICollecti private func removeProductFromCart(_ cartItems: [String], productId: String, cartKey: String, cell: RecommendationsWidgetViewCell) { var updatedCartItems = cartItems updatedCartItems.removeAll { $0 == productId } - UserDefaults.standard.setValue(updatedCartItems, forKey: cartKey) + sdk?.localState?.setCartProducts(updatedCartItems, productId: productId) cell.recommendationsCartButton.setTitle(SdkConfiguration.recommendations.widgetAddToCartButtonText, for: .normal) configureCartButtonAppearance(cell: cell, isInCart: false) } @@ -102,7 +102,7 @@ extension AllSearchResultsViewController: UICollectionViewDataSource, UICollecti private func addProductToCart(_ cartItems: [String], productId: String, cartKey: String, cell: RecommendationsWidgetViewCell) { var updatedCartItems = cartItems updatedCartItems.append(productId) - UserDefaults.standard.setValue(updatedCartItems, forKey: cartKey) + sdk?.localState?.setCartProducts(updatedCartItems, productId: productId) cell.recommendationsCartButton.setTitle(SdkConfiguration.recommendations.widgetRemoveFromCartButtonText, for: .normal) configureCartButtonAppearance(cell: cell, isInCart: true) } @@ -110,20 +110,20 @@ extension AllSearchResultsViewController: UICollectionViewDataSource, UICollecti private func removeProductFromFavorites(_ favoritesItems: [String], productId: String, favoritesKey: String, cell: RecommendationsWidgetViewCell) { var updatedFavoritesItems = favoritesItems updatedFavoritesItems.removeAll { $0 == productId } - UserDefaults.standard.setValue(updatedFavoritesItems, forKey: favoritesKey) + sdk?.localState?.setFavoriteProducts(updatedFavoritesItems, productId: productId) cell.recommendationsFavoritesButton.setImage(UIImage(named: "iconLikeHeartDark"), for: .normal) } private func addProductToFavorites(_ favoritesItems: [String], productId: String, favoritesKey: String, cell: RecommendationsWidgetViewCell) { var updatedFavoritesItems = favoritesItems updatedFavoritesItems.append(productId) - UserDefaults.standard.setValue(updatedFavoritesItems, forKey: favoritesKey) + sdk?.localState?.setFavoriteProducts(updatedFavoritesItems, productId: productId) cell.recommendationsFavoritesButton.setImage(UIImage(named: "iconLikeHeartFillDark"), for: .normal) } private func configureCartButton(cell: RecommendationsWidgetViewCell, productId: String) { let cartKey = "cart.product.\(productId)" - let cartItems = UserDefaults.standard.stringArray(forKey: cartKey) ?? [] + let cartItems = sdk?.localState?.cartProducts(productId: productId) ?? [] let isInCart = cartItems.contains(productId) let buttonText = isInCart ? SdkConfiguration.recommendations.widgetRemoveFromCartButtonText : SdkConfiguration.recommendations.widgetAddToCartButtonText @@ -155,7 +155,7 @@ extension AllSearchResultsViewController: UICollectionViewDataSource, UICollecti private func configureFavoritesButton(cell: RecommendationsWidgetViewCell, productId: String) { let favoritesKey = "favorites.product.\(productId)" - let favoritesItems = UserDefaults.standard.stringArray(forKey: favoritesKey) ?? [] + let favoritesItems = sdk?.localState?.favoriteProducts(productId: productId) ?? [] let isProductFavorited = favoritesItems.contains(productId) diff --git a/REES46/Classes/Stories/StoryViewController/StoryViewController.swift b/REES46/Classes/Stories/StoryViewController/StoryViewController.swift index 0f4e516b..279c23b1 100644 --- a/REES46/Classes/Stories/StoryViewController/StoryViewController.swift +++ b/REES46/Classes/Stories/StoryViewController/StoryViewController.swift @@ -281,7 +281,6 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat } let storyId = stories[currentPosition.section].id - let storyName = "viewed.slide." + storyId guard currentPosition.row < stories[currentPosition.section].slides.count else { print("Row index out of range") @@ -290,14 +289,14 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat let slideId = stories[currentPosition.section].slides[currentPosition.row].id - var viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + var viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) if !viewedStorySlideIdExists { viewedSlidesStoriesCachedArray.append(slideId) - UserDefaults.standard.setValue(viewedSlidesStoriesCachedArray, for: UserDefaults.Key(storyName)) + sdk?.localState?.setViewedSlides(viewedSlidesStoriesCachedArray, storyId: storyId) needSaveStoryLocal = true } @@ -320,7 +319,6 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat } let storyId = stories[currentPosition.section + 1].id - let storyName = "viewed.slide." + storyId guard currentPosition.row < stories[currentPosition.section + 1].slides.count else { print("Row index out of range") @@ -334,7 +332,7 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat allStoriesMainArray.append(stories[currentPosition.section + 1].slides[index].id) } - let viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + let viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) @@ -392,7 +390,6 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat currentPosition.row = 0 let storyId = stories[currentPosition.section - 1].id - let storyName = "viewed.slide." + storyId let slideId = stories[currentPosition.section - 1].slides[currentPosition.row].id var allStoriesMainArray: [String] = [] @@ -400,7 +397,7 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat allStoriesMainArray.append(stories[currentPosition.section - 1].slides[(index)].id) } - let viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + let viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) @@ -461,7 +458,6 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat currentPosition.row = 0 let storyId = stories[currentPosition.section + 1].id - let storyName = "viewed.slide." + storyId let slideId = stories[currentPosition.section + 1].slides[currentPosition.row].id var allStoriesMainArray: [String] = [] @@ -469,7 +465,7 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat allStoriesMainArray.append(stories[currentPosition.section + 1].slides[(index)].id) } - let viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + let viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) @@ -524,7 +520,6 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat currentPosition.row = 0 let storyId = stories[currentPosition.section - 1].id - let storyName = "viewed.slide." + storyId let slideId = stories[currentPosition.section - 1].slides[currentPosition.row].id var allStoriesMainArray: [String] = [] @@ -532,7 +527,7 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat allStoriesMainArray.append(stories[currentPosition.section - 1].slides[(index)].id) } - let viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + let viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) @@ -663,8 +658,8 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat let superviewSlideId = stories[currentPosition.section].slides[currentPosition.row].id let cachedSlideMediaId = "cached.slide." + superviewSlideId - - let imagesForStoriesDownloadedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(cachedSlideMediaId)) as? [String] ?? [] + + let imagesForStoriesDownloadedArray: [String] = sdk?.localState?.downloadedMedia(slideId: superviewSlideId) ?? [] let imageStoryIdDownloaded = imagesForStoriesDownloadedArray.contains(where: { $0.range(of: cachedSlideMediaId) != nil }) @@ -737,15 +732,14 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat currentPosition.row = 0 let storyId = stories[currentPosition.section + 1].id - let storyName = "viewed.slide." + storyId - let slideId = stories[currentPosition.section + 1].slides[currentPosition.row].id + let slideId = stories[currentPosition.section + 1].slides[currentPosition.row].id var allStoriesMainArray: [String] = [] for (index, _) in stories[currentPosition.section + 1].slides.enumerated() { allStoriesMainArray.append(stories[currentPosition.section + 1].slides[(index)].id) } - let viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + let viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) @@ -790,17 +784,16 @@ class StoryViewController: UINavigationController, UINavigationControllerDelegat private func saveStorySlideWatching(index: IndexPath) { let storyId = stories[index.section].id - let storyName = "viewed.slide." + storyId let slideId = stories[index.section].slides[index.row].id - var viewedSlidesStoriesCachedArray: [String] = UserDefaults.standard.getValue(for: UserDefaults.Key(storyName)) as? [String] ?? [] + var viewedSlidesStoriesCachedArray: [String] = sdk?.localState?.viewedSlides(storyId: storyId) ?? [] let viewedStorySlideIdExists = viewedSlidesStoriesCachedArray.contains(where: { $0.range(of: slideId) != nil }) if !viewedStorySlideIdExists { viewedSlidesStoriesCachedArray.append(slideId) - UserDefaults.standard.setValue(viewedSlidesStoriesCachedArray, for: UserDefaults.Key(storyName)) + sdk?.localState?.setViewedSlides(viewedSlidesStoriesCachedArray, storyId: storyId) } } From 30eebc1ab9c9143cf556811f8cf5d240a6e69d47 Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 22:24:32 +0400 Subject: [PATCH 06/10] feat(sdk): public multi-instance Rees46 facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the unified entry point — Rees46.initialize / register(shops:) / instance(for:) / isInitialized / awaitInstance — backed by Rees46Config and a Swift Rees46Error enum (unknownShopId / ambiguousShop). Resolution reuses the internal registry and InstanceResolver; shops can be registered lazily and materialize on first use. Extract makeRegisteredSDK as the single construction path (build, reset the per-shop cache, register) shared by the facade and createPersonalizationSDK, and thread an optional storageKey through it. Deprecate createPersonalizationSDK in favour of the facade — it still works and returns the same type. Add a StoriesView.configure(shopId:) overload that resolves its instance reactively via awaitInstance, and make Cancellable public so the facade can hand it back. Cover the facade with Rees46FacadeTests: resolution, the error contract, lazy registration, and await/cancel. --- DemoApp/REES46.xcodeproj/project.pbxproj | 4 + .../Rees46DemoTests/Rees46FacadeTests.swift | 160 ++++++++++++++ REES46/Classes/Sdk/Facade/Rees46.swift | 209 ++++++++++++++++++ REES46/Classes/Sdk/Facade/Rees46Config.swift | 65 ++++++ REES46/Classes/Sdk/Facade/Rees46Error.swift | 43 ++++ REES46/Classes/Sdk/Registry/Cancellable.swift | 12 +- .../PersonalizationSDK.protocol.swift | 60 ++++- REES46/Classes/Stories/StoriesView.swift | 35 ++- 8 files changed, 573 insertions(+), 15 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/Rees46FacadeTests.swift create mode 100644 REES46/Classes/Sdk/Facade/Rees46.swift create mode 100644 REES46/Classes/Sdk/Facade/Rees46Config.swift create mode 100644 REES46/Classes/Sdk/Facade/Rees46Error.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index e4552078..c54cb1ae 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 75E237932C334AD200119197 /* DeviceIdTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E237912C334AD200119197 /* DeviceIdTest.swift */; }; 7FEFCE64D31526E14ADA2679 /* FakeSDK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */; }; 8A5B5B19354E21262D482DEB /* UserIdentityRepositoryIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */; }; + 92BDE27192EC57DFBC01893C /* Rees46FacadeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EE37E1F7C2A166E0C3DFE89 /* Rees46FacadeTests.swift */; }; 92F69F58F5C797C0D5C31DBE /* UpgradeMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */; }; 9A7A95DF25BC40E900FFA40A /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31DE25A1C6F700D9D1AA /* UserNotifications.framework */; }; 9A7A95E025BC40E900FFA40A /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9ADC31E025A1C6F700D9D1AA /* UserNotificationsUI.framework */; }; @@ -106,6 +107,7 @@ /* Begin PBXFileReference section */ 1C8B363210138CD2A84F29F3 /* Pods-REES46Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Tests.debug.xcconfig"; path = "Target Support Files/Pods-REES46Tests/Pods-REES46Tests.debug.xcconfig"; sourceTree = ""; }; + 2EE37E1F7C2A166E0C3DFE89 /* Rees46FacadeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Rees46FacadeTests.swift; sourceTree = ""; }; 35748965E9FE2297E49752C5 /* Pods-REES46Demo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-REES46Demo.release.xcconfig"; path = "Target Support Files/Pods-REES46Demo/Pods-REES46Demo.release.xcconfig"; sourceTree = ""; }; 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoragePartitionTests.swift; sourceTree = ""; }; 53578CC38C4B3A4AA9973CCC /* REES46.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = REES46.podspec; path = ../REES46.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; @@ -313,6 +315,7 @@ F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */, 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */, B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */, + 2EE37E1F7C2A166E0C3DFE89 /* Rees46FacadeTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -756,6 +759,7 @@ EEF5CF9F6430FD8202BB58BE /* SessionManagerTests.swift in Sources */, 593033DF60A44FEA08C3FAAF /* LocalStateRepositoryTests.swift in Sources */, 92F69F58F5C797C0D5C31DBE /* UpgradeMigrationTests.swift in Sources */, + 92BDE27192EC57DFBC01893C /* Rees46FacadeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/Rees46FacadeTests.swift b/DemoApp/Rees46DemoTests/Rees46FacadeTests.swift new file mode 100644 index 00000000..bccb5800 --- /dev/null +++ b/DemoApp/Rees46DemoTests/Rees46FacadeTests.swift @@ -0,0 +1,160 @@ +// +// Rees46FacadeTests.swift +// REES46Tests +// +// Covers the public `Rees46` facade (R3 / iOS-11): shop resolution, the `Rees46Error` contract, lazy +// registration bookkeeping, and the reactive `awaitInstance` path. Mirror of the Android `Rees46Test`. +// +// Resolution is exercised with `FakeSDK` instances registered into `SdkRegistry` directly, so no real +// (network-spawning) SDK is constructed. The registry holds instances weakly, so each fake is bound +// to a retained local for the test's duration. Materialization of a pending shop (which would build a +// real SDK) is intentionally not driven here — it is covered by the demo E2E (iOS-21). +// + +import XCTest +@testable import REES46 + +final class Rees46FacadeTests: XCTestCase { + + private var registry: SdkRegistry { SdkRegistry.shared } + private var retained: [FakeSDK] = [] + + override func setUp() { + super.setUp() + Rees46.reset() + registry.reset() + } + + override func tearDown() { + Rees46.reset() + registry.reset() + retained.removeAll() + super.tearDown() + } + + private func same(_ lhs: PersonalizationSDK?, _ rhs: PersonalizationSDK?) -> Bool { + (lhs as AnyObject?) === (rhs as AnyObject?) + } + + /// Registers a live `FakeSDK` for `shopId`, retaining it against the registry's weak reference. + @discardableResult + private func live(_ shopId: String) -> FakeSDK { + let sdk = FakeSDK(shopId: shopId) + retained.append(sdk) + registry.register(shopId: shopId, sdk: sdk) + return sdk + } + + // MARK: - instance(for:) resolution + + func test_instance_with_nothing_registered_throws_unknownShopId() { + XCTAssertThrowsError(try Rees46.instance()) { error in + guard case Rees46Error.unknownShopId = error else { + return XCTFail("expected unknownShopId, got \(error)") + } + } + } + + func test_instance_for_an_unknown_shop_throws_unknownShopId() { + live("shop-a") + + XCTAssertThrowsError(try Rees46.instance(for: "shop-x")) { error in + guard case Rees46Error.unknownShopId(let shopId) = error else { + return XCTFail("expected unknownShopId, got \(error)") + } + XCTAssertEqual(shopId, "shop-x") + } + } + + func test_a_single_shop_resolves_without_an_explicit_shopId() throws { + let a = live("shop-a") + XCTAssertTrue(same(try Rees46.instance(), a)) + } + + func test_an_explicit_shopId_resolves_to_its_own_instance() throws { + let a = live("shop-a") + let b = live("shop-b") + + XCTAssertTrue(same(try Rees46.instance(for: "shop-a"), a)) + XCTAssertTrue(same(try Rees46.instance(for: "shop-b"), b)) + } + + func test_no_shopId_with_several_shops_throws_ambiguousShop_listing_them() { + live("shop-a") + live("shop-b") + + XCTAssertThrowsError(try Rees46.instance()) { error in + guard case Rees46Error.ambiguousShop(let shopIds) = error else { + return XCTFail("expected ambiguousShop, got \(error)") + } + XCTAssertEqual(Set(shopIds), ["shop-a", "shop-b"]) + } + } + + // MARK: - isInitialized + + func test_isInitialized_reflects_the_live_instances() { + XCTAssertFalse(Rees46.isInitialized(), "nothing registered") + + live("shop-a") + XCTAssertTrue(Rees46.isInitialized(), "exactly one shop → default is unambiguous") + XCTAssertTrue(Rees46.isInitialized(shopId: "shop-a")) + XCTAssertFalse(Rees46.isInitialized(shopId: "shop-x")) + + live("shop-b") + XCTAssertFalse(Rees46.isInitialized(), "two shops → default is ambiguous") + XCTAssertTrue(Rees46.isInitialized(shopId: "shop-b")) + } + + // MARK: - lazy registration + + func test_register_shops_stays_pending_and_uninitialized() { + Rees46.register(shops: [Rees46Config(shopId: "shop-a"), Rees46Config(shopId: "shop-b")]) + + XCTAssertEqual(Rees46.pendingShopIds(), ["shop-a", "shop-b"]) + XCTAssertTrue(registry.shopIds().isEmpty, "register(shops:) must not initialize anything") + XCTAssertFalse(Rees46.isInitialized(shopId: "shop-a"), "pending is not initialized") + } + + // MARK: - awaitInstance + + func test_awaitInstance_fires_immediately_for_a_live_shop() { + let a = live("shop-a") + + var delivered: PersonalizationSDK? + let handle = Rees46.awaitInstance(for: "shop-a") { delivered = $0 } + + XCTAssertTrue(same(delivered, a)) + handle.cancel() + } + + func test_awaitInstance_drops_an_ambiguous_request() { + live("shop-a") + live("shop-b") + + var fired = false + let handle = Rees46.awaitInstance { _ in fired = true } + + XCTAssertFalse(fired, "an ambiguous await must not deliver the wrong shop") + handle.cancel() + } + + func test_awaitInstance_fires_on_a_later_registration() { + var delivered: PersonalizationSDK? + let handle = Rees46.awaitInstance(for: "shop-late") { delivered = $0 } + XCTAssertNil(delivered, "nothing registered yet") + + let late = live("shop-late") + XCTAssertTrue(same(delivered, late)) + handle.cancel() + } + + func test_awaitInstance_cancel_prevents_a_later_delivery() { + var fired = false + let handle = Rees46.awaitInstance(for: "shop-late") { _ in fired = true } + handle.cancel() + + live("shop-late") + XCTAssertFalse(fired, "a cancelled await must not fire") + } +} diff --git a/REES46/Classes/Sdk/Facade/Rees46.swift b/REES46/Classes/Sdk/Facade/Rees46.swift new file mode 100644 index 00000000..69266943 --- /dev/null +++ b/REES46/Classes/Sdk/Facade/Rees46.swift @@ -0,0 +1,209 @@ +// +// Rees46.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Public entry point for the SDK — the unified, multi-instance API. + + A host no longer needs to hold its own SDK reference: initialize (or register) shops here and reach + them by `shopId` through `instance(for:)`. One instance per shop, each with isolated storage and + state (R2). + + Mirror of the Android `Rees46` facade, adapted to Swift: resolution failures surface as a thrown + `Rees46Error` (not exceptions), and `awaitInstance` is a non-throwing callback API for UI. + + ```swift + // Single shop: + Rees46.initialize(Rees46Config(shopId: "SHOP_ID")) + try Rees46.instance().track(event: .categoryView(id: "1")) + + // Several shops, initialized lazily on first use: + Rees46.register(shops: [Rees46Config(shopId: "shop-a"), Rees46Config(shopId: "shop-b")]) + try Rees46.instance(for: "shop-a").track(event: .categoryView(id: "1")) + ``` + */ +public enum Rees46 { + + /// Shops registered lazily and not yet initialized. Materialized on the first `instance(for:)`. + private static var pending: [String: Rees46Config] = [:] + /// Guards `pending`. The live instances themselves live in the (thread-safe) `SdkRegistry`. + private static let lock = NSLock() + + // MARK: - Initialization + + /** + Initializes an SDK instance for `config` immediately and returns it. The instance is registered, + so it is also reachable via `instance(for:)`. Any pending registration for the same shop is + cleared. + */ + @discardableResult + public static func initialize( + _ config: Rees46Config, + completion: ((SdkError?) -> Void)? = nil + ) -> PersonalizationSDK { + let sdk = makeRegisteredSDK( + shopId: config.shopId, + apiDomain: config.apiDomain, + stream: config.stream, + enableLogs: config.enableLogs, + autoSendPushToken: config.autoSendPushToken, + sendAdvertisingId: config.sendAdvertisingId, + enableAutoPopupPresentation: config.enableAutoPopupPresentation, + needReInitialization: config.needReInitialization, + storageKey: config.storageKey, + completion: completion + ) + lock.lock() + pending[config.shopId] = nil + lock.unlock() + return sdk + } + + /** + Registers `shops` without initializing them. Initialization happens lazily on the first + `instance(for:)` for a shop — the region case, where only the current region is needed. Pass + `eagerInit: true` to initialize every shop up front — the super-shop case, where instances must + stay consistent. + */ + public static func register(shops: [Rees46Config], eagerInit: Bool = false) { + for config in shops { + if eagerInit { + _ = initialize(config) + } else { + lock.lock() + pending[config.shopId] = config + lock.unlock() + } + } + } + + // MARK: - Resolution + + /** + Returns the SDK instance for `shopId`, initializing a pending registration on first use. With no + `shopId`, returns the single instance when exactly one shop is registered. + + - Throws: `Rees46Error.ambiguousShop` when `shopId` is `nil` and more than one shop is registered; + `Rees46Error.unknownShopId` when the shop is unknown (nothing registered, or no such shop). + */ + public static func instance(for shopId: String? = nil) throws -> PersonalizationSDK { + switch InstanceResolver.resolve( + requestedShopId: shopId, + liveShopIds: SdkRegistry.shared.shopIds(), + pendingShopIds: pendingShopIdsSnapshot() + ) { + case .existing(let resolved): + guard let sdk = SdkRegistry.shared.byShopId(resolved) else { + throw Rees46Error.unknownShopId(resolved) + } + return sdk + case .pending(let resolved): + return try materialize(shopId: resolved) + case .notRegistered: + throw Rees46Error.unknownShopId(shopId ?? "") + case .ambiguous: + throw Rees46Error.ambiguousShop(registeredShopIds()) + } + } + + /** + True when an instance is available for `shopId` — or, with no `shopId`, when exactly one shop is + initialized so the default is unambiguous. A pending (registered-but-not-initialized) shop is not + counted as initialized. + */ + public static func isInitialized(shopId: String? = nil) -> Bool { + if let shopId = shopId { + return SdkRegistry.shared.byShopId(shopId) != nil + } + return SdkRegistry.shared.shopIds().count == 1 + } + + /** + Delivers the instance for `shopId` to `onReady` as soon as it is available — immediately if it is + already initialized, otherwise once it registers. A pending registration is initialized on the + spot. With no `shopId` the single default is used, waiting for the first registration when nothing + is registered yet. + + Returns a `Cancellable`; call it when the waiter goes away (e.g. a view detaches) so `onReady` is + not held. Lets a UI element resolve its SDK reactively instead of the host wiring it in. + + iOS divergence from Android (which throws on ambiguity): this is a non-throwing UI callback API, + so an ambiguous request — no `shopId` while several shops are registered — is **dropped** + (`onReady` never fires) rather than delivering the wrong shop. Pass an explicit `shopId` to + disambiguate. + */ + @discardableResult + public static func awaitInstance( + for shopId: String? = nil, + _ onReady: @escaping (PersonalizationSDK) -> Void + ) -> Cancellable { + switch InstanceResolver.resolve( + requestedShopId: shopId, + liveShopIds: SdkRegistry.shared.shopIds(), + pendingShopIds: pendingShopIdsSnapshot() + ) { + case .existing(let resolved): + if let sdk = SdkRegistry.shared.byShopId(resolved) { + onReady(sdk) + } + return .noop + case .pending(let resolved): + if let sdk = try? materialize(shopId: resolved) { + onReady(sdk) + } + return .noop + case .ambiguous: + return .noop + case .notRegistered: + return SdkRegistry.shared.onNextRegister(shopId: shopId, onReady: onReady) + } + } + + // MARK: - Internals + + /// Initializes a pending registration for `shopId`, tolerating a lost race with another caller. + /// The config is claimed atomically so two concurrent materializations never double-initialize. + private static func materialize(shopId: String) throws -> PersonalizationSDK { + lock.lock() + let config = pending.removeValue(forKey: shopId) + lock.unlock() + + if let config = config { + return initialize(config) + } + guard let sdk = SdkRegistry.shared.byShopId(shopId) else { + throw Rees46Error.unknownShopId(shopId) + } + return sdk + } + + private static func pendingShopIdsSnapshot() -> Set { + lock.lock() + defer { lock.unlock() } + return Set(pending.keys) + } + + private static func registeredShopIds() -> [String] { + SdkRegistry.shared.shopIds().union(pendingShopIdsSnapshot()).sorted() + } + + // MARK: - Test hooks + + /// Test-only: drops pending registrations. Live instances live in `SdkRegistry`. + internal static func reset() { + lock.lock() + pending.removeAll() + lock.unlock() + } + + /// Test-only: shops registered lazily and not yet initialized. + internal static func pendingShopIds() -> Set { + pendingShopIdsSnapshot() + } +} diff --git a/REES46/Classes/Sdk/Facade/Rees46Config.swift b/REES46/Classes/Sdk/Facade/Rees46Config.swift new file mode 100644 index 00000000..7cd6636c --- /dev/null +++ b/REES46/Classes/Sdk/Facade/Rees46Config.swift @@ -0,0 +1,65 @@ +// +// Rees46Config.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Configuration for one SDK instance (one shop). + + The unified initialization input shared by `Rees46.initialize` and `Rees46.register(shops:)`. Mirrors + the parameters of the (deprecated) `createPersonalizationSDK`, minus the per-call user identifiers + (`userEmail`/`userPhone`/`userLoyaltyId`) and the `parentViewController` — those are set on the + instance after init. Storage is partitioned per `shopId` automatically (see `StoragePartition`); pass + `storageKey` only to pin a custom suite (parity with Android's `preferencesKey`), which also opts that + shop out of the legacy identity migration. + + Mirror of the Android `Rees46Config`. + */ +public struct Rees46Config { + + /// The shop key — also the storage partition key and the id used by `Rees46.instance(for:)`. + public let shopId: String + /// API host the instance talks to (a region points here). + public let apiDomain: String + /// Traffic stream label sent with every request. + public let stream: String + /// Whether the SDK logs its network activity. + public let enableLogs: Bool + /// Whether the SDK registers push tokens with the backend on its own. + public let autoSendPushToken: Bool + /// Whether the SDK collects and sends the advertising id (IDFA). + public let sendAdvertisingId: Bool + /// Whether the SDK presents init-time popups itself. + public let enableAutoPopupPresentation: Bool + /// Forces a fresh `init` request instead of reusing stored session data. + public let needReInitialization: Bool + /// Custom `UserDefaults` suite name; `nil` uses the per-shop partition (the default). + public let storageKey: String? + + public init( + shopId: String, + apiDomain: String = "api.rees46.ru", + stream: String = "ios", + enableLogs: Bool = false, + autoSendPushToken: Bool = true, + sendAdvertisingId: Bool = false, + enableAutoPopupPresentation: Bool = true, + needReInitialization: Bool = false, + storageKey: String? = nil + ) { + self.shopId = shopId + self.apiDomain = apiDomain + self.stream = stream + self.enableLogs = enableLogs + self.autoSendPushToken = autoSendPushToken + self.sendAdvertisingId = sendAdvertisingId + self.enableAutoPopupPresentation = enableAutoPopupPresentation + self.needReInitialization = needReInitialization + self.storageKey = storageKey + } +} diff --git a/REES46/Classes/Sdk/Facade/Rees46Error.swift b/REES46/Classes/Sdk/Facade/Rees46Error.swift new file mode 100644 index 00000000..c8b11626 --- /dev/null +++ b/REES46/Classes/Sdk/Facade/Rees46Error.swift @@ -0,0 +1,43 @@ +// +// Rees46Error.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Errors raised by `Rees46.instance(for:)` when a shop cannot be resolved. + + A Swift `enum` mirror of the Android `UnknownShopIdException` / `AmbiguousShopException` (decision №1 + — the concepts must match across platforms, expressed idiomatically per platform). + */ +public enum Rees46Error: Error { + + /// No shop is registered for the requested id — nothing registered at all, or no such shop. A + /// registered-but-not-yet-initialized shop does **not** raise this: `instance(for:)` materializes + /// it. The failure is a missing registration, not an instance that failed to start. + case unknownShopId(String) + + /// `instance(for:)` was called without a `shopId` while more than one shop is registered — the + /// default is ambiguous. Carries the sorted registered shop ids. Pass an explicit id. + case ambiguousShop([String]) +} + +extension Rees46Error: LocalizedError { + + public var errorDescription: String? { + switch self { + case .unknownShopId(let shopId): + let subject = shopId.isEmpty + ? "No shop has been registered." + : "No shop is registered for shopId=\(shopId)." + return "\(subject) Call Rees46.initialize(...) or Rees46.register(shops:) first." + case .ambiguousShop(let shopIds): + return "More than one shop is registered — call Rees46.instance(for:) with an explicit " + + "id. Registered: \(shopIds)." + } + } +} diff --git a/REES46/Classes/Sdk/Registry/Cancellable.swift b/REES46/Classes/Sdk/Registry/Cancellable.swift index 99d380e4..f4b23b99 100644 --- a/REES46/Classes/Sdk/Registry/Cancellable.swift +++ b/REES46/Classes/Sdk/Registry/Cancellable.swift @@ -13,14 +13,14 @@ import Foundation cancelled when the view that registered it detaches, so the callback (and whatever it captures) is not leaked. - Mirror of the Android `Cancellable` fun-interface. Kept `internal` in R1 (the registry is the only - caller); the public `Rees46.awaitInstance` facade in R3 will re-expose it. `cancel()` is idempotent - — calling it after the subscription already fired or was cancelled is a no-op. + Mirror of the Android `Cancellable` fun-interface. Public since R3 — `Rees46.awaitInstance` hands it + back to callers — but only the SDK constructs one (the `init` stays `internal`). `cancel()` is + idempotent: calling it after the subscription already fired or was cancelled is a no-op. */ -internal final class Cancellable { +public final class Cancellable { /// A no-op handle for subscriptions that resolved synchronously and hold nothing. - static let noop = Cancellable {} + public static let noop = Cancellable {} private let onCancel: () -> Void private let lock = NSLock() @@ -30,7 +30,7 @@ internal final class Cancellable { self.onCancel = onCancel } - func cancel() { + public func cancel() { lock.lock() let alreadyCancelled = cancelled cancelled = true diff --git a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift index 9668b366..7d65c4f2 100644 --- a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift +++ b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift @@ -315,6 +315,14 @@ public extension PersonalizationSDK { } } +/** + Creates and registers a single SDK instance. + + Deprecated in favour of the multi-instance `Rees46` facade (R3): `Rees46.initialize(_:)` for an eager + instance, or `Rees46.register(shops:)` + `Rees46.instance(for:)` for several shops. It still works and + still registers the instance (same return type), so existing hosts keep compiling and running. + */ +@available(*, deprecated, message: "Use Rees46.initialize(_:) or Rees46.register(shops:) + Rees46.instance(for:)") public func createPersonalizationSDK( shopId: String, userEmail: String? = nil, @@ -328,7 +336,50 @@ public func createPersonalizationSDK( parentViewController: UIViewController? = nil, enableAutoPopupPresentation: Bool = true, needReInitialization: Bool = false, + storageKey: String? = nil, _ completion: ((SdkError?) -> Void)? = nil +) -> PersonalizationSDK { + makeRegisteredSDK( + shopId: shopId, + userEmail: userEmail, + userPhone: userPhone, + userLoyaltyId: userLoyaltyId, + apiDomain: apiDomain, + stream: stream, + enableLogs: enableLogs, + autoSendPushToken: autoSendPushToken, + sendAdvertisingId: sendAdvertisingId, + parentViewController: parentViewController, + enableAutoPopupPresentation: enableAutoPopupPresentation, + needReInitialization: needReInitialization, + storageKey: storageKey, + completion: completion + ) +} + +/** + Builds a `SimplePersonalizationSDK`, resets its per-shop stories cache, and registers it so it can be + resolved by `shop_id` and joins the push fan-out set. + + The single construction path shared by the (soon-deprecated) `createPersonalizationSDK` and the + `Rees46` facade — keeping registration and cache-reset in one place. The registry holds the instance + weakly, so this does not extend its lifetime; it drops out on dealloc or `deleteUserCredentials`. + */ +internal func makeRegisteredSDK( + shopId: String, + userEmail: String? = nil, + userPhone: String? = nil, + userLoyaltyId: String? = nil, + apiDomain: String = "api.rees46.ru", + stream: String = "ios", + enableLogs: Bool = false, + autoSendPushToken: Bool = true, + sendAdvertisingId: Bool = false, + parentViewController: UIViewController? = nil, + enableAutoPopupPresentation: Bool = true, + needReInitialization: Bool = false, + storageKey: String? = nil, + completion: ((SdkError?) -> Void)? = nil ) -> PersonalizationSDK { let sdk = SimplePersonalizationSDK( shopId: shopId, @@ -343,16 +394,11 @@ public func createPersonalizationSDK( parentViewController: parentViewController, enableAutoPopupPresentation: enableAutoPopupPresentation, needReInitialization: needReInitialization, + storageKey: storageKey, completion: completion ) - - sdk.resetSdkCache() - // Multi-instance groundwork (R1): record the instance so it can be resolved by `shop_id` and - // joins the push fan-out set. Single-instance behaviour is unchanged — nothing reads the registry - // yet through the public API. The registry holds the instance weakly, so this does not extend its - // lifetime; it drops out on dealloc or `deleteUserCredentials`. + sdk.resetSdkCache() SdkRegistry.shared.register(shopId: shopId, sdk: sdk) - return sdk } diff --git a/REES46/Classes/Stories/StoriesView.swift b/REES46/Classes/Stories/StoriesView.swift index 88619ce7..be66611c 100644 --- a/REES46/Classes/Stories/StoriesView.swift +++ b/REES46/Classes/Stories/StoriesView.swift @@ -44,7 +44,11 @@ public class StoriesView: UIView, UINavigationControllerDelegate { private var stories: [Story]? private var settings: StoriesSettings? private var sdk: PersonalizationSDK? - + + /// Subscription from the `configure(shopId:...)` overload; torn down when the view goes away or is + /// reconfigured, so the `awaitInstance` callback is not held. + private var sdkAwaitCancellable: Cancellable? + public weak var communicationDelegate: StoriesCommunicationProtocol? private var mainVC: UIViewController? @@ -134,7 +138,34 @@ public class StoriesView: UIView, UINavigationControllerDelegate { self.code = code loadStoriesData() } - + + /** + Multi-instance overload (R3): resolves the SDK instance for `shopId` from the `Rees46` registry + instead of taking one directly, so the host does not have to hold and thread the SDK. With no + `shopId` the single default instance is used. The resolution is reactive — if the shop is not + initialized yet, the view loads as soon as it registers. + + An ambiguous request (no `shopId` while several shops are registered) resolves to nothing and the + view stays empty — pass an explicit `shopId` in a multi-shop app. Reconfiguring cancels any + previous pending resolution. + */ + public func configure(shopId: String? = nil, mainVC: UIViewController, code: String) { + self.mainVC = mainVC + self.code = code + sdkAwaitCancellable?.cancel() + sdkAwaitCancellable = Rees46.awaitInstance(for: shopId) { [weak self] sdk in + DispatchQueue.main.async { + guard let self = self else { return } + self.sdk = sdk + self.loadStoriesData() + } + } + } + + deinit { + sdkAwaitCancellable?.cancel() + } + private func setBgColor() { if SdkConfiguration.isDarkMode { DispatchQueue.main.async { From 6946c94113b240901255d4226f6edd6bb2c6e797 Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Wed, 29 Jul 2026 22:49:17 +0400 Subject: [PATCH 07/10] =?UTF-8?q?feat(push):=20route=20pushes=20to=20the?= =?UTF-8?q?=20instance=20by=20shop=5Fid=20=E2=80=94=20Rees46.handlePush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Rees46.handlePush(_:event:): resolve the target shop from the payload's shop_id via PushTargetResolver, then track received/clicked against that instance. A push for an unknown shop — or with no shop_id while several shops are live — is dropped rather than tracked against the wrong one; a single-instance app still resolves. Navigation stays with the host; handlePush only tracks. Add PushEvent (received/clicked) and a pure PushPayloadParser for shop_id and (type, code); NotificationService.extractTypeAndCode now delegates to it. Make the push-token upload throttle per shop (mainPushTokenLastUploadDateKey .) so one shop's upload no longer throttles the others — each initialized instance registers the device token with its own backend. Cover routing and parsing with HandlePushRoutingTests and PushPayloadParserTests. --- DemoApp/REES46.xcodeproj/project.pbxproj | 8 ++ DemoApp/Rees46DemoTests/FakeSDK.swift | 12 ++- .../HandlePushRoutingTests.swift | 102 ++++++++++++++++++ .../PushPayloadParserTests.swift | 53 +++++++++ .../Notifications/NotificationService.swift | 33 +----- .../Notifications/PushPayloadParser.swift | 77 +++++++++++++ .../Notifications/RegisterNotification.swift | 15 ++- REES46/Classes/Sdk/Facade/Rees46.swift | 32 ++++++ REES46/Classes/Sdk/Model/PushEvent.enum.swift | 23 ++++ 9 files changed, 319 insertions(+), 36 deletions(-) create mode 100644 DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift create mode 100644 DemoApp/Rees46DemoTests/PushPayloadParserTests.swift create mode 100644 REES46/Classes/Notifications/PushPayloadParser.swift create mode 100644 REES46/Classes/Sdk/Model/PushEvent.enum.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index c54cb1ae..cb2a9fde 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -54,8 +54,10 @@ D0B99FA32DB16C960012E4F0 /* SubscribeForPriceDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA22DB16C820012E4F0 /* SubscribeForPriceDrop.swift */; }; D0B99FA52DB16D290012E4F0 /* UnsubscribePriceDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA42DB16D1A0012E4F0 /* UnsubscribePriceDrop.swift */; }; D0B99FA72DB172940012E4F0 /* ManageSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B99FA62DB1728D0012E4F0 /* ManageSubscription.swift */; }; + D0D6C4E39581EE6F13C7E601 /* PushPayloadParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AD555A428611EB01D95215 /* PushPayloadParserTests.swift */; }; D1C0FFEF2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C0FFEE2F0A1B2C3D4E5F60 /* TrackEventCustomFieldsTests.swift */; }; D589BD74A3C4A24BF7BCCD2C /* Pods_REES46Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */; }; + D824C7F34B333C3D68092BA9 /* HandlePushRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 732B3CD050F3471973F52C74 /* HandlePushRoutingTests.swift */; }; DFDB6BC32790E30AA243B4E9 /* StoragePartitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */; }; E9049B762B1F561100BD7FEB /* Inter-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B752B1F561100BD7FEB /* Inter-Bold.ttf */; }; E9049B782B1F56D700BD7FEB /* Inter-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9049B772B1F56D700BD7FEB /* Inter-Semibold.ttf */; }; @@ -126,6 +128,7 @@ 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SdkRegistryTests.swift; sourceTree = ""; }; 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; + 732B3CD050F3471973F52C74 /* HandlePushRoutingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HandlePushRoutingTests.swift; sourceTree = ""; }; 7587149B2C6CDF62008E5E4E /* SDKConfigProviderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKConfigProviderTests.swift; sourceTree = ""; }; 758714A62C6CF1EB008E5E4E /* AppEnvironments.struct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppEnvironments.struct.swift; sourceTree = ""; }; 75A4D60B2C5B8EAA00929368 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -162,6 +165,7 @@ AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FakeSDK.swift; sourceTree = ""; }; B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InstanceResolverTests.swift; sourceTree = ""; }; B57812A2700376CF0EC5D913 /* Pods_REES46Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B5AD555A428611EB01D95215 /* PushPayloadParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushPayloadParserTests.swift; sourceTree = ""; }; B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UpgradeMigrationTests.swift; sourceTree = ""; }; CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeychainInitStoreTests.swift; sourceTree = ""; }; D005D80C2DB2CDE000841D78 /* MockGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenerator.swift; sourceTree = ""; }; @@ -316,6 +320,8 @@ 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */, B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */, 2EE37E1F7C2A166E0C3DFE89 /* Rees46FacadeTests.swift */, + B5AD555A428611EB01D95215 /* PushPayloadParserTests.swift */, + 732B3CD050F3471973F52C74 /* HandlePushRoutingTests.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -760,6 +766,8 @@ 593033DF60A44FEA08C3FAAF /* LocalStateRepositoryTests.swift in Sources */, 92F69F58F5C797C0D5C31DBE /* UpgradeMigrationTests.swift in Sources */, 92BDE27192EC57DFBC01893C /* Rees46FacadeTests.swift in Sources */, + D0D6C4E39581EE6F13C7E601 /* PushPayloadParserTests.swift in Sources */, + D824C7F34B333C3D68092BA9 /* HandlePushRoutingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Rees46DemoTests/FakeSDK.swift b/DemoApp/Rees46DemoTests/FakeSDK.swift index 94f03aae..f4f2155f 100644 --- a/DemoApp/Rees46DemoTests/FakeSDK.swift +++ b/DemoApp/Rees46DemoTests/FakeSDK.swift @@ -29,6 +29,10 @@ final class FakeSDK: PersonalizationSDK { var enableAutoPopupPresentation: Bool = true lazy var popupPresenter: PopupPresenter = PopupPresenter(sdk: self) + /// Records notification-track calls so tests can assert push routing hit the right instance. + private(set) var receivedTracks: [(type: String, code: String)] = [] + private(set) var clickedTracks: [(type: String, code: String)] = [] + init(shopId: String = "fake-shop") { self.shopId = shopId } @@ -66,9 +70,13 @@ final class FakeSDK: PersonalizationSDK { func review(rate: Int, channel: String, category: String, orderId: String?, comment: String?, completion: @escaping (Result) -> Void) {} func searchBlank(completion: @escaping (Result) -> Void) {} func search(query: String, limit: Int?, offset: Int?, categoryLimit: Int?, brandLimit: Int?, categories: [Int]?, extended: String?, sortBy: String?, sortDir: String?, locations: String?, excludedMerchants: [String]?, excludedBrands: [String]?, brands: String?, filters: [String: Any]?, priceMin: Double?, priceMax: Double?, colors: [String]?, fashionSizes: [String]?, exclude: String?, email: String?, timeOut: Double?, disableClarification: Bool?, completion: @escaping (Result) -> Void) {} - func notificationClicked(type: String, code: String, completion: @escaping (Result) -> Void) {} + func notificationClicked(type: String, code: String, completion: @escaping (Result) -> Void) { + clickedTracks.append((type, code)) + } func notificationDelivered(type: String, code: String, completion: @escaping (Result) -> Void) {} - func notificationReceived(type: String, code: String, completion: @escaping (Result) -> Void) {} + func notificationReceived(type: String, code: String, completion: @escaping (Result) -> Void) { + receivedTracks.append((type, code)) + } func subscribeForBackInStock(id: String, email: String?, phone: String?, fashionSize: String?, fashionColor: String?, barcode: String?, completion: @escaping (Result) -> Void) {} func unsubscribeForBackInStock(itemIds: [String], email: String?, phone: String?, completion: @escaping (Result) -> Void) {} func subscribeForPriceDrop(id: String, currentPrice: Double, email: String?, phone: String?, completion: @escaping (Result) -> Void) {} diff --git a/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift new file mode 100644 index 00000000..2b7f3d51 --- /dev/null +++ b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift @@ -0,0 +1,102 @@ +// +// HandlePushRoutingTests.swift +// REES46Tests +// +// Covers `Rees46.handlePush` (R4 / iOS-15): a push is tracked against the instance its `shop_id` +// names, and is dropped rather than mis-tracked when the shop is unknown or the target is ambiguous. +// Mirror of the Android push-routing tests. Uses `FakeSDK`, which records its notification-track +// calls, so routing is asserted without any network. The registry holds instances weakly, so each +// fake is bound to a retained local. +// + +import XCTest +@testable import REES46 + +final class HandlePushRoutingTests: XCTestCase { + + private var registry: SdkRegistry { SdkRegistry.shared } + private var retained: [FakeSDK] = [] + + override func setUp() { + super.setUp() + Rees46.reset() + registry.reset() + } + + override func tearDown() { + Rees46.reset() + registry.reset() + retained.removeAll() + super.tearDown() + } + + @discardableResult + private func live(_ shopId: String) -> FakeSDK { + let sdk = FakeSDK(shopId: shopId) + retained.append(sdk) + registry.register(shopId: shopId, sdk: sdk) + return sdk + } + + private func payload(shopId: String?, type: String = "web", code: String = "c1") -> [AnyHashable: Any] { + var p: [AnyHashable: Any] = ["type": type, "id": code] + if let shopId = shopId { p["shop_id"] = shopId } + return p + } + + func test_clicked_is_tracked_against_the_shop_named_in_the_payload() { + let a = live("shop-a") + let b = live("shop-b") + + Rees46.handlePush(payload(shopId: "shop-a", type: "product", code: "p1"), event: .clicked) + + XCTAssertEqual(a.clickedTracks.count, 1) + XCTAssertEqual(a.clickedTracks.first?.type, "product") + XCTAssertEqual(a.clickedTracks.first?.code, "p1") + XCTAssertTrue(b.clickedTracks.isEmpty, "the other shop must not be tracked") + } + + func test_received_is_tracked_against_the_shop_named_in_the_payload() { + let a = live("shop-a") + let b = live("shop-b") + + Rees46.handlePush(payload(shopId: "shop-b", type: "category", code: "c9"), event: .received) + + XCTAssertEqual(b.receivedTracks.map { $0.code }, ["c9"]) + XCTAssertTrue(a.receivedTracks.isEmpty) + } + + func test_an_unknown_shop_id_drops_the_push() { + let a = live("shop-a") + + Rees46.handlePush(payload(shopId: "shop-x"), event: .clicked) + + XCTAssertTrue(a.clickedTracks.isEmpty, "a push for an unknown shop must not fall through to a live one") + } + + func test_no_shop_id_with_a_single_instance_still_delivers() { + let a = live("shop-a") + + Rees46.handlePush(payload(shopId: nil, code: "solo"), event: .clicked) + + XCTAssertEqual(a.clickedTracks.map { $0.code }, ["solo"]) + } + + func test_no_shop_id_with_several_instances_drops_the_push() { + let a = live("shop-a") + let b = live("shop-b") + + Rees46.handlePush(payload(shopId: nil), event: .clicked) + + XCTAssertTrue(a.clickedTracks.isEmpty) + XCTAssertTrue(b.clickedTracks.isEmpty) + } + + func test_a_non_sdk_push_is_ignored_even_for_a_valid_shop() { + let a = live("shop-a") + + Rees46.handlePush(["shop_id": "shop-a", "title": "Hi"], event: .received) + + XCTAssertTrue(a.receivedTracks.isEmpty, "a payload without type/code is not an SDK push") + } +} diff --git a/DemoApp/Rees46DemoTests/PushPayloadParserTests.swift b/DemoApp/Rees46DemoTests/PushPayloadParserTests.swift new file mode 100644 index 00000000..0d78227b --- /dev/null +++ b/DemoApp/Rees46DemoTests/PushPayloadParserTests.swift @@ -0,0 +1,53 @@ +// +// PushPayloadParserTests.swift +// REES46Tests +// +// Covers the pure push-payload extraction (R4 / iOS-15) shared by `Rees46.handlePush` and the legacy +// `NotificationService`: the target `shop_id` and the `(type, code)` a track call carries, across the +// payload shapes the SDK accepts. +// + +import XCTest +@testable import REES46 + +final class PushPayloadParserTests: XCTestCase { + + func test_shopId_is_read_from_the_payload() { + XCTAssertEqual(PushPayloadParser.shopId(from: ["shop_id": "shop-a"]), "shop-a") + } + + func test_shopId_is_nil_when_absent() { + XCTAssertNil(PushPayloadParser.shopId(from: ["type": "web", "id": "c1"])) + } + + func test_type_and_code_from_top_level_fields() { + let result = PushPayloadParser.typeAndCode(from: ["type": "product", "id": "p1"]) + XCTAssertEqual(result?.type, "product") + XCTAssertEqual(result?.code, "p1") + } + + func test_type_from_event_json_with_top_level_id() { + let payload: [AnyHashable: Any] = [ + "event": "{\"type\":\"category\",\"uri\":\"https://x/y\"}", + "id": "c1" + ] + let result = PushPayloadParser.typeAndCode(from: payload) + XCTAssertEqual(result?.type, "category") + XCTAssertEqual(result?.code, "c1") + } + + func test_type_and_code_from_nested_src_json() { + let payload: [AnyHashable: Any] = ["src": "{\"type\":\"web\",\"id\":\"s1\"}"] + let result = PushPayloadParser.typeAndCode(from: payload) + XCTAssertEqual(result?.type, "web") + XCTAssertEqual(result?.code, "s1") + } + + func test_type_and_code_is_nil_for_a_non_sdk_push() { + XCTAssertNil(PushPayloadParser.typeAndCode(from: ["title": "Hi", "body": "there"])) + } + + func test_type_and_code_is_nil_when_code_is_missing() { + XCTAssertNil(PushPayloadParser.typeAndCode(from: ["type": "web"])) + } +} diff --git a/REES46/Classes/Notifications/NotificationService.swift b/REES46/Classes/Notifications/NotificationService.swift index 3e8ac6e3..1300c665 100644 --- a/REES46/Classes/Notifications/NotificationService.swift +++ b/REES46/Classes/Notifications/NotificationService.swift @@ -205,38 +205,9 @@ public class NotificationService: NotificationServiceProtocol { } } - private func extractValue(for key: String, from userInfo: [AnyHashable: Any]) -> String? { - if let value = userInfo[key] as? String { - return value - } - if let src = parseDictionary(key: Constants.srcKey, userInfo: userInfo), - let value = src[key] as? String { - return value - } - return nil - } - public func extractTypeAndCode(from userInfo: [AnyHashable: Any]) -> (type: String, code: String)? { - - let id = extractValue(for: Constants.idKey, from: userInfo) - - if let eventJSON = parseDictionary(key: Constants.eventKey, userInfo: userInfo), - let eventType = eventJSON[Constants.typeKey] as? String { - - if let id { - return (eventType, id) - } - if let srcID = userInfo[Constants.idKey] as? [String: Any] { - if let value = srcID[Constants.idKey] as? String { - return (eventType, value) - } - } - } - if let type = extractValue(for: Constants.typeKey, from: userInfo), - let id { - return (type, id) - } - return nil + // Shared with `Rees46.handlePush` so both paths accept the same payload shapes. + PushPayloadParser.typeAndCode(from: userInfo) } public func parseDictionary(key: String, userInfo: [AnyHashable: Any]) -> [String: Any]? { diff --git a/REES46/Classes/Notifications/PushPayloadParser.swift b/REES46/Classes/Notifications/PushPayloadParser.swift new file mode 100644 index 00000000..413be886 --- /dev/null +++ b/REES46/Classes/Notifications/PushPayloadParser.swift @@ -0,0 +1,77 @@ +// +// PushPayloadParser.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + Pure extraction of the fields the SDK needs from a raw APNs/FCM push payload — the target `shop_id` + and the `(type, code)` a track call carries. + + Side-effect-free and instance-free, so both `Rees46.handlePush` (multi-instance routing) and the + legacy `NotificationService` share one parsing story. Mirrors what `NotificationService` extracted + inline before; the accepted payload shapes are unchanged (a top-level `type`/`id`, an `event` JSON + string carrying `type`, or a nested `src`/`id` dictionary). + */ +enum PushPayloadParser { + + private enum Keys { + static let shopId = "shop_id" + static let type = "type" + static let id = "id" + static let event = "event" + static let src = "src" + } + + /// The shop a push is addressed to, or `nil` when the payload carries no `shop_id`. + static func shopId(from payload: [AnyHashable: Any]) -> String? { + payload[Keys.shopId] as? String + } + + /// The `(type, code)` a track call needs, or `nil` when the payload is not an SDK push. + static func typeAndCode(from payload: [AnyHashable: Any]) -> (type: String, code: String)? { + let id = value(for: Keys.id, in: payload) + + if let eventJSON = dictionary(for: Keys.event, in: payload), + let eventType = eventJSON[Keys.type] as? String { + if let id = id { + return (eventType, id) + } + if let srcID = payload[Keys.id] as? [String: Any], + let value = srcID[Keys.id] as? String { + return (eventType, value) + } + } + + if let type = value(for: Keys.type, in: payload), let id = id { + return (type, id) + } + return nil + } + + /// A string value under `key`, looked up at the top level and then inside a `src` JSON string. + private static func value(for key: String, in payload: [AnyHashable: Any]) -> String? { + if let value = payload[key] as? String { + return value + } + if let src = dictionary(for: Keys.src, in: payload), + let value = src[key] as? String { + return value + } + return nil + } + + /// Parses a JSON-string field into a dictionary; `nil` on a missing field or malformed JSON. + private static func dictionary(for key: String, in payload: [AnyHashable: Any]) -> [String: Any]? { + guard let jsonString = payload[key] as? String, + let data = jsonString.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []), + let dict = object as? [String: Any] + else { return nil } + return dict + } +} diff --git a/REES46/Classes/Notifications/RegisterNotification.swift b/REES46/Classes/Notifications/RegisterNotification.swift index 2a6b6a3d..e45dbc7a 100644 --- a/REES46/Classes/Notifications/RegisterNotification.swift +++ b/REES46/Classes/Notifications/RegisterNotification.swift @@ -14,9 +14,18 @@ class NotificationRegistrar { init(sdk: PersonalizationSDK) { self.sdk = sdk } - + + /// Per-shop throttle key: the APNs token is device-global, but each shop uploads it to its own + /// backend on its own weekly schedule. A single global key let the first shop's upload throttle + /// every other shop for a week (so their backends never got the token). Namespacing by `shop_id` + /// makes each initialized instance register the token independently — the iOS analog of Android's + /// per-shop `PushTokenManager` throttle, and what implicitly fans the token out across shops. + private var lastUploadDateKey: String { + "\(Constants.mainPushTokenLastUploadDateKey).\(sdk.shopId)" + } + func registerWithDeviceToken(deviceToken: Data) { - if let pushTokenLastUpdateDate = UserDefaults.standard.object(forKey: Constants.mainPushTokenLastUploadDateKey) as? Date { + if let pushTokenLastUpdateDate = UserDefaults.standard.object(forKey: lastUploadDateKey) as? Date { let currentDate = Date() let timeSincePushTokenLastUpdate = currentDate.timeIntervalSince(pushTokenLastUpdateDate) guard timeSincePushTokenLastUpdate >= Constants.oneWeekInSeconds else { @@ -35,7 +44,7 @@ class NotificationRegistrar { switch tokenResponse { case .success(): let currentDate = Date() - UserDefaults.standard.setValue(currentDate, forKey: Constants.mainPushTokenLastUploadDateKey) + UserDefaults.standard.setValue(currentDate, forKey: self.lastUploadDateKey) let nextPossibleSendDate = currentDate.addingTimeInterval(Constants.oneWeekInSeconds) #if DEBUG print("Push token successfully sent. Last upload date: \(currentDate). Next possible send date: \(nextPossibleSendDate)") diff --git a/REES46/Classes/Sdk/Facade/Rees46.swift b/REES46/Classes/Sdk/Facade/Rees46.swift index 69266943..ecb6fc2e 100644 --- a/REES46/Classes/Sdk/Facade/Rees46.swift +++ b/REES46/Classes/Sdk/Facade/Rees46.swift @@ -165,6 +165,38 @@ public enum Rees46 { } } + // MARK: - Push + + /** + Routes a push to the instance it belongs to and tracks `event` for it. The target is the payload's + `shop_id`; with no `shop_id` a single-instance app still resolves, but an unknown shop — or an + absent `shop_id` while several shops are live — **drops** the push (via `PushTargetResolver`) + instead of tracking it against the wrong one. A payload that is not an SDK push (no resolvable + `type`/`code`) is ignored. Call this from a host that owns its messaging service. + + `received` tracks `track/received`; `clicked` tracks `track/clicked`. Navigation stays with the + host: unlike the Android singleton, iOS routes click actions through the host's own deep-link + handling (or the legacy `NotificationService`'s action delegate), so this method tracks but does + not open the target screen. + */ + public static func handlePush(_ payload: [AnyHashable: Any], event: PushEvent) { + guard let shopId = PushTargetResolver.resolve( + payloadShopId: PushPayloadParser.shopId(from: payload), + liveShopIds: SdkRegistry.shared.shopIds() + ), let sdk = SdkRegistry.shared.byShopId(shopId) else { + return + } + guard let (type, code) = PushPayloadParser.typeAndCode(from: payload) else { + return + } + switch event { + case .received: + sdk.notificationReceived(type: type, code: code) { _ in } + case .clicked: + sdk.notificationClicked(type: type, code: code) { _ in } + } + } + // MARK: - Internals /// Initializes a pending registration for `shopId`, tolerating a lost race with another caller. diff --git a/REES46/Classes/Sdk/Model/PushEvent.enum.swift b/REES46/Classes/Sdk/Model/PushEvent.enum.swift new file mode 100644 index 00000000..645f1671 --- /dev/null +++ b/REES46/Classes/Sdk/Model/PushEvent.enum.swift @@ -0,0 +1,23 @@ +// +// PushEvent.enum.swift +// REES46 +// +// Created by REES46 +// Copyright (c) 2023. All rights reserved. +// + +import Foundation + +/** + The push lifecycle event a host reports through `Rees46.handlePush(_:event:)`. + + `received` tracks delivery (`track/received`); `clicked` tracks the tap (`track/clicked`). Navigation + (opening the product/category/web the push points to) stays with the host — see `Rees46.handlePush`. + + Mirror of the Android `PushEventType`, renamed on iOS to avoid a clash with the existing + `PushEventType` (which names the push *content* type: web/category/product/carousel/custom). + */ +public enum PushEvent { + case received + case clicked +} From d94fdec15d775338334d8302cce78d9fc9c556fe Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Thu, 30 Jul 2026 13:57:57 +0400 Subject: [PATCH 08/10] feat(push): route NotificationService tracking through Rees46.handlePush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationService tracked delivered/received/clicked against the single sdk it captured at init, so in a multi-shop app every push was attributed to the wrong shop. Route all three through Rees46.handlePush, which resolves the target by the payload's shop_id; navigation, token registration and notification categories stay in the service. Add PushEvent.delivered so the delivery beacon is routed too. The service no longer drives notificationTrackerDelegate (handlePush does the tracking now, so calling it would double-track); NotificationTracker is kept for source compatibility but is inert. Single-shop behaviour is unchanged — handlePush resolves the one live shop, the same instance the service captured. Extend HandlePushRoutingTests with the delivered case. --- DemoApp/Rees46DemoTests/FakeSDK.swift | 5 ++- .../HandlePushRoutingTests.swift | 10 ++++++ .../Notifications/NotificationService.swift | 32 ++++++++----------- .../Tracker/NotificationTracker.swift | 3 ++ REES46/Classes/Sdk/Facade/Rees46.swift | 10 +++--- REES46/Classes/Sdk/Model/PushEvent.enum.swift | 14 +++++--- 6 files changed, 47 insertions(+), 27 deletions(-) diff --git a/DemoApp/Rees46DemoTests/FakeSDK.swift b/DemoApp/Rees46DemoTests/FakeSDK.swift index f4f2155f..457e3390 100644 --- a/DemoApp/Rees46DemoTests/FakeSDK.swift +++ b/DemoApp/Rees46DemoTests/FakeSDK.swift @@ -30,6 +30,7 @@ final class FakeSDK: PersonalizationSDK { lazy var popupPresenter: PopupPresenter = PopupPresenter(sdk: self) /// Records notification-track calls so tests can assert push routing hit the right instance. + private(set) var deliveredTracks: [(type: String, code: String)] = [] private(set) var receivedTracks: [(type: String, code: String)] = [] private(set) var clickedTracks: [(type: String, code: String)] = [] @@ -73,7 +74,9 @@ final class FakeSDK: PersonalizationSDK { func notificationClicked(type: String, code: String, completion: @escaping (Result) -> Void) { clickedTracks.append((type, code)) } - func notificationDelivered(type: String, code: String, completion: @escaping (Result) -> Void) {} + func notificationDelivered(type: String, code: String, completion: @escaping (Result) -> Void) { + deliveredTracks.append((type, code)) + } func notificationReceived(type: String, code: String, completion: @escaping (Result) -> Void) { receivedTracks.append((type, code)) } diff --git a/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift index 2b7f3d51..a3901ce2 100644 --- a/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift +++ b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift @@ -56,6 +56,16 @@ final class HandlePushRoutingTests: XCTestCase { XCTAssertTrue(b.clickedTracks.isEmpty, "the other shop must not be tracked") } + func test_delivered_is_tracked_against_the_shop_named_in_the_payload() { + let a = live("shop-a") + let b = live("shop-b") + + Rees46.handlePush(payload(shopId: "shop-a", type: "web", code: "d1"), event: .delivered) + + XCTAssertEqual(a.deliveredTracks.map { $0.code }, ["d1"]) + XCTAssertTrue(b.deliveredTracks.isEmpty, "the other shop must not be tracked") + } + func test_received_is_tracked_against_the_shop_named_in_the_payload() { let a = live("shop-a") let b = live("shop-b") diff --git a/REES46/Classes/Notifications/NotificationService.swift b/REES46/Classes/Notifications/NotificationService.swift index 1300c665..7505fd99 100644 --- a/REES46/Classes/Notifications/NotificationService.swift +++ b/REES46/Classes/Notifications/NotificationService.swift @@ -30,6 +30,11 @@ public class NotificationService: NotificationServiceProtocol { } public var pushActionDelegate: NotificationActionsProtocol? + + /// Retained for source compatibility. Notification tracking now routes through `Rees46.handlePush` + /// (which resolves the shop by `shop_id`), so this service no longer drives the delegate — a delegate + /// that called back into a single captured `sdk` would mis-track in a multi-shop app and double-track + /// alongside `handlePush`. `NotificationTracker` still constructs but is inert. public var notificationTrackerDelegate: NotificationTrackerDelegate? public let sdk: PersonalizationSDK @@ -78,8 +83,11 @@ public class NotificationService: NotificationServiceProtocol { fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult, String) -> Void ) { - notificationDelivered(userInfo: userInfo) - + // Track against the shop the push names (multi-instance): `Rees46.handlePush` resolves the target + // by `shop_id` instead of the single sdk captured at init, which would otherwise mis-track in a + // multi-shop app. Navigation below stays with this service / the host. + Rees46.handlePush(userInfo, event: .delivered) + switch application.applicationState { case .active: notificationLogger.log("Application is in active state, processing notification") @@ -150,8 +158,8 @@ public class NotificationService: NotificationServiceProtocol { handleNonSDKPush(userInfo: userInfo) return } - - notificationClicked(type: eventType, code: srcID) + + Rees46.handlePush(userInfo, event: .clicked) if let eventLink = parseDictionary(key: Constants.eventKey, userInfo: userInfo)?[Constants.uriKey] as? String { var modifiedEventLink = eventLink @@ -172,8 +180,8 @@ public class NotificationService: NotificationServiceProtocol { handleNonSDKPush(userInfo: userInfo) return } - - notificationReceived(type: type, code: code) + + Rees46.handlePush(userInfo, event: .received) guard let eventJSON = parseDictionary(key: Constants.eventKey, userInfo: userInfo), let eventType = eventJSON[Constants.typeKey] as? String, @@ -228,18 +236,6 @@ public class NotificationService: NotificationServiceProtocol { return nil } - private func notificationClicked(type: String, code: String){ - notificationTrackerDelegate?.notificationClicked(type: type, code: code) - } - - private func notificationReceived(type: String, code: String){ - notificationTrackerDelegate?.notificationReceived(type: type, code: code) - } - - private func notificationDelivered(userInfo: [AnyHashable: Any]){ - notificationTrackerDelegate?.notificationDelivered(userInfo: userInfo) - } - private func openCategory(categoryId: String) { pushActionDelegate?.openCategory(categoryId: categoryId) } diff --git a/REES46/Classes/Notifications/Tracker/NotificationTracker.swift b/REES46/Classes/Notifications/Tracker/NotificationTracker.swift index e29ac2c3..32d86558 100644 --- a/REES46/Classes/Notifications/Tracker/NotificationTracker.swift +++ b/REES46/Classes/Notifications/Tracker/NotificationTracker.swift @@ -1,5 +1,8 @@ import Foundation +/// Superseded by `Rees46.handlePush`, which tracks against the shop the push names (`shop_id`) instead +/// of a single captured `sdk`. `NotificationService` no longer drives this delegate, so constructing a +/// `NotificationTracker` is now a no-op kept for source compatibility. New code should not rely on it. public class NotificationTracker: NotificationTrackerDelegate{ private let notificationLogger: NotificationLogger diff --git a/REES46/Classes/Sdk/Facade/Rees46.swift b/REES46/Classes/Sdk/Facade/Rees46.swift index ecb6fc2e..881e5a01 100644 --- a/REES46/Classes/Sdk/Facade/Rees46.swift +++ b/REES46/Classes/Sdk/Facade/Rees46.swift @@ -174,10 +174,10 @@ public enum Rees46 { instead of tracking it against the wrong one. A payload that is not an SDK push (no resolvable `type`/`code`) is ignored. Call this from a host that owns its messaging service. - `received` tracks `track/received`; `clicked` tracks `track/clicked`. Navigation stays with the - host: unlike the Android singleton, iOS routes click actions through the host's own deep-link - handling (or the legacy `NotificationService`'s action delegate), so this method tracks but does - not open the target screen. + `delivered`/`received`/`clicked` track `track/delivered`/`track/received`/`track/clicked`. + Navigation stays with the host: unlike the Android singleton, iOS routes click actions through the + host's own deep-link handling (or the legacy `NotificationService`'s action delegate), so this + method tracks but does not open the target screen. */ public static func handlePush(_ payload: [AnyHashable: Any], event: PushEvent) { guard let shopId = PushTargetResolver.resolve( @@ -190,6 +190,8 @@ public enum Rees46 { return } switch event { + case .delivered: + sdk.notificationDelivered(type: type, code: code) { _ in } case .received: sdk.notificationReceived(type: type, code: code) { _ in } case .clicked: diff --git a/REES46/Classes/Sdk/Model/PushEvent.enum.swift b/REES46/Classes/Sdk/Model/PushEvent.enum.swift index 645f1671..c6dc30c8 100644 --- a/REES46/Classes/Sdk/Model/PushEvent.enum.swift +++ b/REES46/Classes/Sdk/Model/PushEvent.enum.swift @@ -11,13 +11,19 @@ import Foundation /** The push lifecycle event a host reports through `Rees46.handlePush(_:event:)`. - `received` tracks delivery (`track/received`); `clicked` tracks the tap (`track/clicked`). Navigation - (opening the product/category/web the push points to) stays with the host — see `Rees46.handlePush`. + - `delivered` tracks arrival (`track/delivered`) — the raw beacon fired on every push that reaches the + running app. + - `received` tracks a background arrival (`track/received`) — the push landed while the app was + backgrounded, with no interaction. + - `clicked` tracks the tap (`track/clicked`). - Mirror of the Android `PushEventType`, renamed on iOS to avoid a clash with the existing - `PushEventType` (which names the push *content* type: web/category/product/carousel/custom). + Navigation (opening the product/category/web the push points to) stays with the host — see + `Rees46.handlePush`. Superset of the Android `PushEventType` (received/clicked): iOS keeps the separate + `delivered` beacon its `NotificationService` already emitted. Renamed to `PushEvent` to avoid a clash + with the existing `PushEventType` (the push *content* type: web/category/product/carousel/custom). */ public enum PushEvent { + case delivered case received case clicked } From 479bf6b204277b16c6be4d53b02b93cd70d55a9f Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Fri, 31 Jul 2026 22:00:43 +0400 Subject: [PATCH 09/10] feat(demo): two-shop multi-instance demo screen and live E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MultiInstanceViewController: two shops live in one app, each resolved by shopId through Rees46.instance(for:) with no globalSDK — session cards, fail-fast contract buttons, push-routing buttons, per-shop stories via the StoriesView shopId overload, and a log. Reachable from a button on the main screen, whose DID label now reads getDeviceId() instead of the pre-partition UserDefaults.standard key. Add MultiInstanceE2ETest against the live backend: two shops resolve to distinct instances, the default is ambiguous, an unknown shop throws, and the two shops keep isolated sessions (distinct client seances; the did is device-level). Point the tests at the real test shop — the previous default was deactivated and the backend returns "shop not found" for it — and add a second real shop. Update DeviceIdSaveTest to assert the did persists in the shop partition, not the shared UserDefaults.standard key. --- DemoApp/REES46.xcodeproj/project.pbxproj | 8 + DemoApp/REES46Demo/MainViewController.swift | 30 ++- .../MultiInstanceViewController.swift | 228 ++++++++++++++++++ DemoApp/Rees46DemoTests/Constants.swift | 10 +- DemoApp/Rees46DemoTests/DeviceIdTest.swift | 61 ++++- .../MultiInstanceE2ETest.swift | 106 ++++++++ 6 files changed, 429 insertions(+), 14 deletions(-) create mode 100644 DemoApp/REES46Demo/MultiInstanceViewController.swift create mode 100644 DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index cb2a9fde..c7a28372 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0169B35A9F893F6D8D22820A /* Pods_REES46Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */; }; 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */; }; 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */; }; + 447EFA0789368C931564CA42 /* MultiInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661B8BEEE8EEBD5EA66BDEE8 /* MultiInstanceViewController.swift */; }; 593033DF60A44FEA08C3FAAF /* LocalStateRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */; }; 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 607FACD81AFB9204008FA782 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* MainViewController.swift */; }; @@ -64,6 +65,7 @@ E9CF44412B30809C000F53F1 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CF44402B30809C000F53F1 /* SearchViewController.swift */; }; E9EAB8672AF08A43003DC1B5 /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E9EAB8662AF08A43003DC1B5 /* Inter.ttf */; }; EEF5CF9F6430FD8202BB58BE /* SessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */; }; + EFCBC17B792379A0DACE7B9D /* MultiInstanceE2ETest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FDDEEF9BF2D37D8410CBFA2 /* MultiInstanceE2ETest.swift */; }; F0605CC99B325F3301237A28 /* StorageMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */; }; F808067588CEF0AA10BCA7D7 /* UserIdentityRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */; }; /* End PBXBuildFile section */ @@ -126,6 +128,7 @@ 607FACE51AFB9204008FA782 /* REES46Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = REES46Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SdkRegistryTests.swift; sourceTree = ""; }; + 661B8BEEE8EEBD5EA66BDEE8 /* MultiInstanceViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultiInstanceViewController.swift; sourceTree = ""; }; 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_REES46Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageMigrationTests.swift; sourceTree = ""; }; 732B3CD050F3471973F52C74 /* HandlePushRoutingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HandlePushRoutingTests.swift; sourceTree = ""; }; @@ -138,6 +141,7 @@ 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushTargetResolverTests.swift; sourceTree = ""; }; 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserIdentityRepositoryIsolationTests.swift; sourceTree = ""; }; 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserIdentityRepositoryTests.swift; sourceTree = ""; }; + 8FDDEEF9BF2D37D8410CBFA2 /* MultiInstanceE2ETest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultiInstanceE2ETest.swift; sourceTree = ""; }; 9A58AAB128E80C33004EA84C /* REESNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = REESNotificationServiceExtension.entitlements; sourceTree = ""; }; 9A58AAB228E80EEA004EA84C /* REESNotificationContentExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = REESNotificationContentExtension.entitlements; sourceTree = ""; }; 9A7A95DE25BC40E900FFA40A /* REESNotificationContentExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = REESNotificationContentExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -285,6 +289,7 @@ 607FACDC1AFB9204008FA782 /* Images.xcassets */, 9AFB9EE328044E4D001462AC /* PushPresent */, 607FACD31AFB9204008FA782 /* Supporting Files */, + 661B8BEEE8EEBD5EA66BDEE8 /* MultiInstanceViewController.swift */, ); name = Rees46Demo; path = REES46Demo; @@ -322,6 +327,7 @@ 2EE37E1F7C2A166E0C3DFE89 /* Rees46FacadeTests.swift */, B5AD555A428611EB01D95215 /* PushPayloadParserTests.swift */, 732B3CD050F3471973F52C74 /* HandlePushRoutingTests.swift */, + 8FDDEEF9BF2D37D8410CBFA2 /* MultiInstanceE2ETest.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -729,6 +735,7 @@ E9CF44412B30809C000F53F1 /* SearchViewController.swift in Sources */, 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */, 758714A82C6CF1EB008E5E4E /* AppEnvironments.struct.swift in Sources */, + 447EFA0789368C931564CA42 /* MultiInstanceViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -768,6 +775,7 @@ 92BDE27192EC57DFBC01893C /* Rees46FacadeTests.swift in Sources */, D0D6C4E39581EE6F13C7E601 /* PushPayloadParserTests.swift in Sources */, D824C7F34B333C3D68092BA9 /* HandlePushRoutingTests.swift in Sources */, + EFCBC17B792379A0DACE7B9D /* MultiInstanceE2ETest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/REES46Demo/MainViewController.swift b/DemoApp/REES46Demo/MainViewController.swift index c4c0ef37..544fd4b0 100644 --- a/DemoApp/REES46Demo/MainViewController.swift +++ b/DemoApp/REES46Demo/MainViewController.swift @@ -67,6 +67,7 @@ class MainViewController: UIViewController, UIScrollViewDelegate { private var getProductCountersButton: UIButton! private var getCategoryButton: UIButton! private var getCollectionButton: UIButton! + private var multiInstanceButton: UIButton! public var waitIndicator: SdkActivityIndicator! @@ -274,7 +275,7 @@ class MainViewController: UIViewController, UIScrollViewDelegate { func setupSdkDemoAppViews() { navigationController?.navigationBar.isHidden = true - scrollView.contentSize = CGSize(width: UIScreen.main.bounds.size.width, height: 2000) + scrollView.contentSize = CGSize(width: UIScreen.main.bounds.size.width, height: 2100) menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) searchButton.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside) @@ -295,6 +296,7 @@ class MainViewController: UIViewController, UIScrollViewDelegate { setupGetProductCountersButton() setupGetCategoryButton() setupGetCollectionButton() + setupMultiInstanceButton() fontInterPreload() DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { @@ -312,7 +314,8 @@ class MainViewController: UIViewController, UIScrollViewDelegate { fcmGlobalToken = UserDefaults.standard.string(forKey: "fcmGlobalToken") ?? "No Firebase token" } - let deviceId = UserDefaults.standard.string(forKey: "device_id") ?? "No did token" + // R2: the did lives in the shop's partition, not `.standard` — read it from the instance. + let deviceId = globalSDK?.getDeviceId() ?? "No did token" self.didLabel.text = "DID\n\n" + deviceId self.pushTokenLabel.text = "PUSHTOKEN\n\n" + pushGlobalToken @@ -765,6 +768,29 @@ class MainViewController: UIViewController, UIScrollViewDelegate { getCollectionButton.addTarget(self, action: #selector(didTapGetCollection), for: .touchUpInside) } + func setupMultiInstanceButton() { + multiInstanceButton = DemoShopButton(type: .system) + multiInstanceButton.setTitle("Multi-instance demo", for: .normal) + multiInstanceButton.setTitleColor(.white, for: .normal) + multiInstanceButton.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(multiInstanceButton) + + NSLayoutConstraint.activate([ + multiInstanceButton.topAnchor.constraint(equalTo: getCollectionButton.bottomAnchor, constant: 10), + multiInstanceButton.leadingAnchor.constraint(equalTo: showStoriesButton.leadingAnchor), + multiInstanceButton.widthAnchor.constraint(equalTo: showStoriesButton.widthAnchor), + multiInstanceButton.heightAnchor.constraint(equalTo: showStoriesButton.heightAnchor), + ]) + + multiInstanceButton.addTarget(self, action: #selector(didTapMultiInstance), for: .touchUpInside) + } + + @objc private func didTapMultiInstance() { + let nav = UINavigationController(rootViewController: MultiInstanceViewController()) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + } + @objc private func didTapGetCollection() { guard let sdk = globalSDK else { diff --git a/DemoApp/REES46Demo/MultiInstanceViewController.swift b/DemoApp/REES46Demo/MultiInstanceViewController.swift new file mode 100644 index 00000000..5408630b --- /dev/null +++ b/DemoApp/REES46Demo/MultiInstanceViewController.swift @@ -0,0 +1,228 @@ +// +// MultiInstanceViewController.swift +// REES46Demo +// +// Multi-instance demo (iOS-21): two shops live in one app, each resolved by `shopId` through the +// public `Rees46` facade — no `globalSDK`. Every request an instance sends carries its own +// shop_id/did, so the two sessions stay isolated. Mirror of the Android `MultiInstancePane`. +// + +import UIKit +import REES46 + +final class MultiInstanceViewController: UIViewController { + + // Two real demo shops (parity with the Android demo's SHOP_ID / SHOP_ID_2). + private let shopA = "357382bf66ac0ce2f1722677c59511" + private let shopB = "4b464e7c386120d4b621bf7cb79293" + private let storiesCodeA = "115bd0b67cf5989a1b60c841790b2af6" + private let storiesCodeB = "3704bd6e8a1a9a6dc65b75a5f01e5c8d" + + private let cardA = SessionCardView(title: "Shop A (default)") + private let cardB = SessionCardView(title: "Shop B") + private let logView = UITextView() + private var logLines: [String] = [] + private let maxLoggedEvents = 40 + + override func viewDidLoad() { + super.viewDidLoad() + title = "Multi-instance" + view.backgroundColor = .white + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, target: self, action: #selector(close) + ) + + buildLayout() + + // Both shops initialized at once, each with its own session and storage. Resolution below goes + // through Rees46.instance(for:) — the host holds no SDK reference of its own. + Rees46.initialize(Rees46Config(shopId: shopA, enableAutoPopupPresentation: false)) + Rees46.initialize(Rees46Config(shopId: shopB, enableAutoPopupPresentation: false)) + refreshSessions() + } + + // MARK: - Layout + + private func buildLayout() { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + let scroll = UIScrollView() + scroll.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scroll) + scroll.addSubview(stack) + + NSLayoutConstraint.activate([ + scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 16), + stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -16), + stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -16), + ]) + + stack.addArrangedSubview(title("Two shops, one app")) + stack.addArrangedSubview(subtitle("Both shops are initialized at once, each with its own session and storage. Actions below resolve instances by shopId through Rees46.")) + + stack.addArrangedSubview(cardA) + stack.addArrangedSubview(cardB) + stack.addArrangedSubview(button("Refresh sessions", #selector(refreshSessions))) + + stack.addArrangedSubview(title("Fail-fast contracts")) + stack.addArrangedSubview(button("instance() with 2 shops → ambiguous", #selector(tapAmbiguous))) + stack.addArrangedSubview(button("instance(\"nope\") → unknown", #selector(tapUnknown))) + + stack.addArrangedSubview(title("Push routing (Rees46.handlePush)")) + stack.addArrangedSubview(button("push shop_id=A", #selector(tapPushA))) + stack.addArrangedSubview(button("push shop_id=B", #selector(tapPushB))) + stack.addArrangedSubview(button("push shop_id=unknown", #selector(tapPushUnknown))) + stack.addArrangedSubview(button("push (no shop_id)", #selector(tapPushNone))) + + stack.addArrangedSubview(title("Per-shop stories")) + stack.addArrangedSubview(storiesView(shopId: shopA, code: storiesCodeA)) + stack.addArrangedSubview(storiesView(shopId: shopB, code: storiesCodeB)) + + logView.isEditable = false + logView.font = UIFont(name: "Menlo", size: 12) ?? .systemFont(ofSize: 12) + logView.backgroundColor = UIColor(white: 0.95, alpha: 1.0) + logView.text = "Results of contract and push-routing actions appear here" + logView.heightAnchor.constraint(equalToConstant: 160).isActive = true + stack.addArrangedSubview(logView) + } + + private func title(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .boldSystemFont(ofSize: 17) + label.numberOfLines = 0 + return label + } + + private func subtitle(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 13) + label.textColor = UIColor.gray + label.numberOfLines = 0 + return label + } + + private func button(_ text: String, _ action: Selector) -> UIButton { + let btn = UIButton(type: .system) + btn.setTitle(text, for: .normal) + btn.contentHorizontalAlignment = .leading + btn.addTarget(self, action: action, for: .touchUpInside) + return btn + } + + private func storiesView(shopId: String, code: String) -> UIView { + let container = UIView() + container.backgroundColor = .white // stories label colors assume a light backdrop + container.heightAnchor.constraint(equalToConstant: 120).isActive = true + + let stories = StoriesView() + stories.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(stories) + NSLayoutConstraint.activate([ + stories.topAnchor.constraint(equalTo: container.topAnchor), + stories.leadingAnchor.constraint(equalTo: container.leadingAnchor), + stories.trailingAnchor.constraint(equalTo: container.trailingAnchor), + stories.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + // iOS-13 overload: the widget resolves its own instance by shopId, no sdk passed in. + stories.configure(shopId: shopId, mainVC: self, code: code) + return container + } + + // MARK: - Actions + + @objc private func refreshSessions() { + cardA.update(session: session(for: shopA)) + cardB.update(session: session(for: shopB)) + log("refreshed sessions") + } + + private func session(for shopId: String) -> (did: String, seance: String)? { + guard let sdk = try? Rees46.instance(for: shopId) else { return nil } + return (sdk.getDeviceId(), sdk.getSession()) + } + + @objc private func tapAmbiguous() { log(resolveLog { try Rees46.instance() }) } + @objc private func tapUnknown() { log(resolveLog { try Rees46.instance(for: "nope") }) } + + @objc private func tapPushA() { routePush(shopId: shopA, note: "routes to A") } + @objc private func tapPushB() { routePush(shopId: shopB, note: "routes to B") } + @objc private func tapPushUnknown() { routePush(shopId: "unknown", note: "dropped (unknown shop)") } + @objc private func tapPushNone() { routePush(shopId: nil, note: "dropped (2 shops live, ambiguous)") } + + private func routePush(shopId: String?, note: String) { + var payload: [AnyHashable: Any] = ["type": "bulk", "id": "demo"] + if let shopId = shopId { payload["shop_id"] = shopId } + Rees46.handlePush(payload, event: .received) + log("push shop_id=\(shopId ?? "—") → \(note)") + } + + /// Runs a throwing resolve and turns the outcome (an instance or the fail-fast error) into a line. + private func resolveLog(_ resolve: () throws -> PersonalizationSDK) -> String { + do { + let sdk = try resolve() + return "resolved shop \(sdk.getShopId())" + } catch { + return "\((error as? LocalizedError)?.errorDescription ?? "\(error)")" + } + } + + private func log(_ message: String) { + logLines.insert(message, at: 0) + if logLines.count > maxLoggedEvents { logLines.removeLast() } + logView.text = logLines.joined(separator: "\n") + } + + @objc private func close() { dismiss(animated: true) } +} + +/// A labelled did/seance readout for one shop. +final class SessionCardView: UIView { + + private let titleLabel = UILabel() + private let bodyLabel = UILabel() + + init(title: String) { + super.init(frame: .zero) + backgroundColor = UIColor(white: 0.95, alpha: 1.0) + layer.cornerRadius = 8 + + titleLabel.text = title + titleLabel.font = .boldSystemFont(ofSize: 15) + bodyLabel.font = UIFont(name: "Menlo", size: 12) ?? .systemFont(ofSize: 12) + bodyLabel.numberOfLines = 0 + bodyLabel.textColor = UIColor.gray + + let stack = UIStackView(arrangedSubviews: [titleLabel, bodyLabel]) + stack.axis = .vertical + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor, constant: 10), + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10), + ]) + update(session: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func update(session: (did: String, seance: String)?) { + if let session = session { + bodyLabel.text = "did=\(session.did.isEmpty ? "resolving…" : session.did)\nseance=\(session.seance)" + } else { + bodyLabel.text = "resolving…" + } + } +} diff --git a/DemoApp/Rees46DemoTests/Constants.swift b/DemoApp/Rees46DemoTests/Constants.swift index b694e209..1dca671b 100644 --- a/DemoApp/Rees46DemoTests/Constants.swift +++ b/DemoApp/Rees46DemoTests/Constants.swift @@ -10,6 +10,7 @@ struct Constants { static let defaultTimeout = 20.0 static let testShopIdKey = "TEST_SHOP_ID" + static let testShopIdKey2 = "TEST_SHOP_ID_2" static let testApiUrlKey = "TEST_API_URL" /// Shop id used by the integration tests. Resolved from the CI-injected environment @@ -18,12 +19,19 @@ struct Constants { ProcessInfo.processInfo.environment[testShopIdKey] ?? defaultShopId } + /// Second (distinct, real) shop id for the multi-instance E2E. Resolved from `TEST_SHOP_ID_2`, + /// falling back to a real demo shop — parity with Android's `SHOP_ID_2`. + static var testShopIdB: String { + ProcessInfo.processInfo.environment[testShopIdKey2] ?? defaultShopIdB + } + /// API domain used by the integration tests. Resolved from the CI-injected environment /// (`TEST_API_URL`), falling back to the local default when the variable is not set. static var testApiDomain: String { ProcessInfo.processInfo.environment[testApiUrlKey] ?? defaultApiDomain } - private static let defaultShopId = "357382bf66ac0ce2f1722677c59511" + private static let defaultShopId = "c1140c8254976de297c3caf971701a" + private static let defaultShopIdB = "4b464e7c386120d4b621bf7cb79293" private static let defaultApiDomain = "api.rees46.ru" } diff --git a/DemoApp/Rees46DemoTests/DeviceIdTest.swift b/DemoApp/Rees46DemoTests/DeviceIdTest.swift index 95be9327..0253f677 100644 --- a/DemoApp/Rees46DemoTests/DeviceIdTest.swift +++ b/DemoApp/Rees46DemoTests/DeviceIdTest.swift @@ -1,15 +1,54 @@ +// +// DeviceIdTest.swift +// REES46Tests +// +// R2: the did is no longer stored in the shared `UserDefaults.standard` under "device_id" — it lives +// in the shop's partition (`personalization_sdk_`). This pins that location and that two shops +// keep their dids apart, so a regression back to a global key would fail here. +// + import XCTest -import REES46 - -class DeviceIdSaveTest: XCTestCase { - - // First we start the application so that the deviceId is saved, then we run this test - func test_is_device_id_haved_second_run() { - let deviceId = UserDefaults.standard.string(forKey: "device_id") ?? "No did token" // Get DeviceId from storage - if deviceId.isEmpty{ - XCTAssert(false, "deviceId bad, init sdk, rerun test") - } else { - XCTAssert(true, "deviceId good") +@testable import REES46 + +final class DeviceIdSaveTest: XCTestCase { + + private var shops: [String] = [] + + private func freshShop() -> String { + let shop = "device-id-test-\(UUID().uuidString)" + shops.append(shop) + return shop + } + + override func tearDown() { + for shop in shops { + UserDefaults.standard.removePersistentDomain(forName: StoragePartition.suiteName(for: shop)) } + shops.removeAll() + super.tearDown() + } + + func test_device_id_persists_in_the_shop_partition() { + let shop = freshShop() + let repo = UserIdentityRepositoryImpl(shopId: shop) + + repo.deviceId = "did-token" + + XCTAssertEqual( + StoragePartition.store(for: shop).string(forKey: StoragePartition.deviceIdKey), + "did-token", + "the did must persist in the shop's partition" + ) + } + + func test_two_shops_do_not_share_a_device_id() { + let shopA = freshShop() + let shopB = freshShop() + + UserIdentityRepositoryImpl(shopId: shopA).deviceId = "did-A" + UserIdentityRepositoryImpl(shopId: shopB).deviceId = "did-B" + + XCTAssertEqual(UserIdentityRepositoryImpl(shopId: shopA).deviceId, "did-A") + XCTAssertEqual(UserIdentityRepositoryImpl(shopId: shopB).deviceId, "did-B", "each shop keeps its own did") } } diff --git a/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift b/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift new file mode 100644 index 00000000..0db162f2 --- /dev/null +++ b/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift @@ -0,0 +1,106 @@ +// +// MultiInstanceE2ETest.swift +// REES46Tests +// +// On-device E2E for multi-instance (iOS-21) against the live REES46 API. Initializes two distinct, +// real shops in one process through the public `Rees46` facade and asserts their isolation and the +// fail-fast resolution contracts. Mirror of the Android `MultiInstanceE2ETest`. +// +// `Rees46` / `SdkRegistry` are process-global, and the demo app registers its own shop at launch, so +// each test resets the registry in setUp and makes the shops it needs live itself — assertions are +// order-independent. The session-isolation test needs the backend; the resolution-contract tests do +// not (registration is synchronous), so they hold even offline. +// + +import XCTest +@testable import REES46 + +final class MultiInstanceE2ETest: XCTestCase { + + private let shopA = Constants.testShopId + private let shopB = Constants.testShopIdB + private let apiDomain = Constants.testApiDomain + + override func setUp() { + super.setUp() + Rees46.reset() + SdkRegistry.shared.reset() + } + + override func tearDown() { + Rees46.reset() + SdkRegistry.shared.reset() + super.tearDown() + } + + private func config(_ shopId: String) -> Rees46Config { + Rees46Config( + shopId: shopId, + apiDomain: apiDomain, + enableAutoPopupPresentation: false, + needReInitialization: true + ) + } + + private func same(_ lhs: PersonalizationSDK?, _ rhs: PersonalizationSDK?) -> Bool { + (lhs as AnyObject?) === (rhs as AnyObject?) + } + + // MARK: - Resolution contracts (no backend needed) + + func test_both_shops_resolve_to_distinct_instances() throws { + let a = Rees46.initialize(config(shopA)) + let b = Rees46.initialize(config(shopB)) + + XCTAssertFalse(same(a, b), "each shop must have its own SDK instance") + XCTAssertTrue(same(try Rees46.instance(for: shopA), a)) + XCTAssertTrue(same(try Rees46.instance(for: shopB), b)) + } + + func test_default_instance_is_ambiguous_with_two_shops_live() { + _ = Rees46.initialize(config(shopA)) + _ = Rees46.initialize(config(shopB)) + + XCTAssertThrowsError(try Rees46.instance()) { error in + guard case Rees46Error.ambiguousShop = error else { + return XCTFail("expected ambiguousShop, got \(error)") + } + } + } + + func test_instance_for_an_unknown_shop_throws_unknownShopId() { + _ = Rees46.initialize(config(shopA)) + + XCTAssertThrowsError(try Rees46.instance(for: "nope-not-a-shop")) { error in + guard case Rees46Error.unknownShopId = error else { + return XCTFail("expected unknownShopId, got \(error)") + } + } + } + + // MARK: - Session isolation (live backend) + + func test_two_shops_have_isolated_sessions() { + let (didA, sidA) = initAndWaitSession(shopA) + let (didB, sidB) = initAndWaitSession(shopB) + + // Both shops complete /init against the live backend, each carrying its own session. + XCTAssertFalse(didA.isEmpty, "shop A did not receive a did") + XCTAssertFalse(didB.isEmpty, "shop B did not receive a did") + // The did is a device-level identifier — the backend assigns one did per device, shared across + // the shops on it (neither shop sends the other's did: prepareRequestParameters drops it under + // needReInitialization). Per-shop isolation shows in the session: each shop's partition holds + // its own client-owned seance, so the two seances differ. + XCTAssertNotEqual(sidA, sidB, "each shop must keep its own session (seance)") + } + + /// Initializes one shop and blocks until its `/init` completes, returning its live session. + private func initAndWaitSession(_ shopId: String) -> (did: String, seance: String) { + let ready = expectation(description: "\(shopId) /init") + var err: SdkError? + let sdk = Rees46.initialize(config(shopId)) { err = $0; ready.fulfill() } + wait(for: [ready], timeout: Constants.defaultTimeout) + XCTAssertNil(err, "\(shopId) init error: \(String(describing: err))") + return (sdk.getDeviceId(), sdk.getSession()) + } +} From 3dc1d8c93fdbcb0ffe370da0aee5a6ef418f9cfc Mon Sep 17 00:00:00 2001 From: xyzcancer Date: Thu, 13 Aug 2026 10:11:46 +0400 Subject: [PATCH 10/10] feat(sdk): route pending-shop pushes and drop dead PushTargetResolver - Rees46.handlePush: resolve via InstanceResolver (live + pending) and track a registered-but-not-initialized shop's push standalone (PushDeliveryTracker) instead of dropping it - remove the now-unused PushTargetResolver and its tests - SdkRegistry: fix a stale routing comment - tests: pending-push routing, upgrade migration --- DemoApp/REES46.xcodeproj/project.pbxproj | 4 - .../HandlePushRoutingTests.swift | 83 ++++++++++++ .../MultiInstanceE2ETest.swift | 90 ++++++++++++- .../PushTargetResolverTests.swift | 41 ------ .../UpgradeMigrationTests.swift | 53 ++++++++ REES46/Classes/Sdk/Facade/Rees46.swift | 121 +++++++++++++++--- .../Sdk/Registry/PushTargetResolver.swift | 27 ---- REES46/Classes/Sdk/Registry/SdkRegistry.swift | 3 +- 8 files changed, 328 insertions(+), 94 deletions(-) delete mode 100644 DemoApp/Rees46DemoTests/PushTargetResolverTests.swift delete mode 100644 REES46/Classes/Sdk/Registry/PushTargetResolver.swift diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index c7a28372..08c8bf0d 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 0169B35A9F893F6D8D22820A /* Pods_REES46Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F0B9704F35259B141245836 /* Pods_REES46Demo.framework */; }; 020434BEE53FB36FF675571D /* KeychainInitStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */; }; - 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */; }; 447EFA0789368C931564CA42 /* MultiInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661B8BEEE8EEBD5EA66BDEE8 /* MultiInstanceViewController.swift */; }; 593033DF60A44FEA08C3FAAF /* LocalStateRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */; }; 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; @@ -138,7 +137,6 @@ 75D1D5C02CA72BF200C8C564 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 75E2378E2C334AA800119197 /* SdkTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SdkTest.swift; sourceTree = ""; }; 75E237912C334AD200119197 /* DeviceIdTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdTest.swift; sourceTree = ""; }; - 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushTargetResolverTests.swift; sourceTree = ""; }; 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserIdentityRepositoryIsolationTests.swift; sourceTree = ""; }; 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserIdentityRepositoryTests.swift; sourceTree = ""; }; 8FDDEEF9BF2D37D8410CBFA2 /* MultiInstanceE2ETest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultiInstanceE2ETest.swift; sourceTree = ""; }; @@ -313,7 +311,6 @@ 607FACE91AFB9204008FA782 /* Supporting Files */, AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */, B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */, - 78EB6EC250D2FE2569EEF80E /* PushTargetResolverTests.swift */, 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */, 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */, 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */, @@ -761,7 +758,6 @@ D005D80D2DB2CDE900841D78 /* MockGenerator.swift in Sources */, 7FEFCE64D31526E14ADA2679 /* FakeSDK.swift in Sources */, 6805322F7DF2A2E2F1234B24 /* InstanceResolverTests.swift in Sources */, - 147DA400223F6CCA5AC65661 /* PushTargetResolverTests.swift in Sources */, 67B36DC5275BCA31AB0A0363 /* SdkRegistryTests.swift in Sources */, DFDB6BC32790E30AA243B4E9 /* StoragePartitionTests.swift in Sources */, F0605CC99B325F3301237A28 /* StorageMigrationTests.swift in Sources */, diff --git a/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift index a3901ce2..c7a24bae 100644 --- a/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift +++ b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift @@ -109,4 +109,87 @@ final class HandlePushRoutingTests: XCTestCase { XCTAssertTrue(a.receivedTracks.isEmpty, "a payload without type/code is not an SDK push") } + + // MARK: - Pending (registered-but-not-initialized) shops + + // A pending shop has no instance, so `handlePush` tracks it standalone (no construction/init). The + // real tracker POSTs over the network; swap in a recorder so the pending path is asserted offline + // — this pins parity with Android (materializeForPush) and RN (standalone trackPush): a push for a + // registered shop is NOT dropped just because the shop is not live yet. + private struct PendingTrack: Equatable { + let shopId: String + let event: String + let type: String + let code: String + } + + private func recordPendingTracks(into sink: @escaping (PendingTrack) -> Void) { + Rees46.pendingPushTracker = { shopId, _, event, type, code in + let name: String + switch event { + case .delivered: name = "delivered" + case .received: name = "received" + case .clicked: name = "clicked" + } + sink(PendingTrack(shopId: shopId, event: name, type: type, code: code)) + } + } + + func test_a_push_for_a_registered_but_pending_shop_is_tracked_without_initializing_it() { + Rees46.register(shops: [Rees46Config(shopId: "shop-lazy")]) // pending, not initialized + var tracks: [PendingTrack] = [] + recordPendingTracks { tracks.append($0) } + + Rees46.handlePush(payload(shopId: "shop-lazy", type: "product", code: "p9"), event: .delivered) + + XCTAssertEqual( + tracks, + [PendingTrack(shopId: "shop-lazy", event: "delivered", type: "product", code: "p9")] + ) + XCTAssertNil(registry.byShopId("shop-lazy"), "a push must NOT construct/register the shop") + XCTAssertTrue(Rees46.pendingShopIds().contains("shop-lazy"), "the shop stays pending after a push") + } + + func test_a_clicked_push_for_a_pending_shop_is_tracked_standalone() { + Rees46.register(shops: [Rees46Config(shopId: "shop-lazy")]) + var tracks: [PendingTrack] = [] + recordPendingTracks { tracks.append($0) } + + Rees46.handlePush(payload(shopId: "shop-lazy", type: "web", code: "c3"), event: .clicked) + + XCTAssertEqual(tracks.map { $0.event }, ["clicked"]) + XCTAssertEqual(tracks.first?.code, "c3") + } + + func test_no_shop_id_with_a_single_pending_shop_falls_back_to_it() { + Rees46.register(shops: [Rees46Config(shopId: "shop-lazy")]) + var tracks: [PendingTrack] = [] + recordPendingTracks { tracks.append($0) } + + Rees46.handlePush(payload(shopId: nil, code: "solo"), event: .delivered) + + XCTAssertEqual(tracks.map { $0.shopId }, ["shop-lazy"]) + } + + func test_a_pending_shop_push_without_type_or_code_is_ignored() { + Rees46.register(shops: [Rees46Config(shopId: "shop-lazy")]) + var tracks: [PendingTrack] = [] + recordPendingTracks { tracks.append($0) } + + Rees46.handlePush(["shop_id": "shop-lazy", "title": "Hi"], event: .delivered) // no type/code + + XCTAssertTrue(tracks.isEmpty, "a payload without type/code is not an SDK push") + } + + func test_no_shop_id_with_one_live_and_one_pending_shop_is_ambiguous_and_dropped() { + let a = live("shop-a") + Rees46.register(shops: [Rees46Config(shopId: "shop-lazy")]) + var tracks: [PendingTrack] = [] + recordPendingTracks { tracks.append($0) } + + Rees46.handlePush(payload(shopId: nil), event: .delivered) + + XCTAssertTrue(tracks.isEmpty, "a pending shop counts toward ambiguity — no-shop_id must drop") + XCTAssertTrue(a.deliveredTracks.isEmpty) + } } diff --git a/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift b/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift index 0db162f2..d7e71f57 100644 --- a/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift +++ b/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift @@ -8,8 +8,11 @@ // // `Rees46` / `SdkRegistry` are process-global, and the demo app registers its own shop at launch, so // each test resets the registry in setUp and makes the shops it needs live itself — assertions are -// order-independent. The session-isolation test needs the backend; the resolution-contract tests do -// not (registration is synchronous), so they hold even offline. +// order-independent. Only `test_two_shops_have_isolated_sessions` needs the live backend (it waits on +// `/init` for the did); every other test here is deterministic without the network. The lifecycle +// tests (lazy materialization, eagerInit, awaitInstance-on-pending) and the offline session-isolation +// test build real SDKs but never await `/init` — they assert only the synchronous registry/session +// state the facade establishes at construction, so they hold even with no backend. // import XCTest @@ -21,6 +24,10 @@ final class MultiInstanceE2ETest: XCTestCase { private let shopB = Constants.testShopIdB private let apiDomain = Constants.testApiDomain + /// The registry holds instances weakly, so real SDKs built by these tests are retained here for the + /// test's duration (an in-flight `/init` also retains them, but this keeps assertions robust). + private var retained: [PersonalizationSDK] = [] + override func setUp() { super.setUp() Rees46.reset() @@ -30,9 +37,16 @@ final class MultiInstanceE2ETest: XCTestCase { override func tearDown() { Rees46.reset() SdkRegistry.shared.reset() + retained.removeAll() super.tearDown() } + @discardableResult + private func retain(_ sdk: PersonalizationSDK) -> PersonalizationSDK { + retained.append(sdk) + return sdk + } + private func config(_ shopId: String) -> Rees46Config { Rees46Config( shopId: shopId, @@ -78,6 +92,78 @@ final class MultiInstanceE2ETest: XCTestCase { } } + // MARK: - Lifecycle: lazy materialization / eagerInit / awaitInstance (no backend) + + /// The core lazy-multi-instance promise: `register(shops:)` only records the config; the SDK is not + /// built until the first `instance(for:)`, which materializes it and clears the pending entry. + func test_a_lazily_registered_shop_materializes_on_first_use() throws { + Rees46.register(shops: [config(shopA)]) + + XCTAssertEqual(Rees46.pendingShopIds(), [shopA], "register(shops:) leaves the shop pending") + XCTAssertFalse(Rees46.isInitialized(shopId: shopA), "a pending shop is not initialized") + XCTAssertTrue(SdkRegistry.shared.shopIds().isEmpty, "nothing is built until first use") + + let sdk = retain(try Rees46.instance(for: shopA)) + + XCTAssertEqual(sdk.getShopId(), shopA, "first use builds the SDK for the pending shop") + XCTAssertTrue(Rees46.pendingShopIds().isEmpty, "materialization consumes the pending entry") + XCTAssertTrue(Rees46.isInitialized(shopId: shopA), "the shop is live after first use") + XCTAssertTrue(SdkRegistry.shared.shopIds().contains(shopA)) + } + + /// A pending shop must materialize exactly once — the second resolution returns the same instance, + /// never a rebuild (guards the atomic claim in `Rees46.materialize`). + func test_materialization_is_idempotent_and_returns_the_same_instance() throws { + Rees46.register(shops: [config(shopA)]) + + let first = retain(try Rees46.instance(for: shopA)) + let second = try Rees46.instance(for: shopA) + + XCTAssertTrue(same(first, second), "a pending shop must materialize once, not rebuild") + } + + /// `eagerInit: true` is the super-shop case — every shop is built up front, none left pending. + func test_eagerInit_initializes_every_shop_up_front() throws { + Rees46.register(shops: [config(shopA), config(shopB)], eagerInit: true) + + XCTAssertTrue(Rees46.pendingShopIds().isEmpty, "eagerInit leaves nothing pending") + XCTAssertEqual(SdkRegistry.shared.shopIds(), [shopA, shopB], "both shops are live immediately") + XCTAssertTrue(Rees46.isInitialized(shopId: shopA)) + XCTAssertTrue(Rees46.isInitialized(shopId: shopB)) + XCTAssertEqual(retain(try Rees46.instance(for: shopA)).getShopId(), shopA) + XCTAssertEqual(retain(try Rees46.instance(for: shopB)).getShopId(), shopB) + } + + /// `awaitInstance` on a pending shop builds it on the spot and delivers it, consuming the pending + /// entry — the reactive path a UI element uses to resolve its SDK without the host wiring it in. + func test_awaitInstance_materializes_a_pending_shop_on_the_spot() { + Rees46.register(shops: [config(shopA)]) + + var delivered: PersonalizationSDK? + let handle = Rees46.awaitInstance(for: shopA) { delivered = self.retain($0) } + + XCTAssertEqual(delivered?.getShopId(), shopA, "awaitInstance builds the pending shop and delivers it") + XCTAssertTrue(Rees46.pendingShopIds().isEmpty, "the pending entry is consumed") + XCTAssertTrue(Rees46.isInitialized(shopId: shopA)) + handle.cancel() + } + + // MARK: - Session isolation without the backend + + /// The seance is client-owned and assigned synchronously at construction from each shop's own + /// storage partition (the `/init` response deliberately does not adopt it — see SimplePersonalizationSDK), + /// so per-shop isolation is observable immediately, with no network. This is the deterministic + /// sibling of `test_two_shops_have_isolated_sessions`, which waits on `/init` only for the did. + func test_two_shops_keep_isolated_sessions_without_the_backend() { + let a = retain(Rees46.initialize(config(shopA))) + let b = retain(Rees46.initialize(config(shopB))) + + XCTAssertNotEqual(a.getShopId(), b.getShopId(), "each shop keeps its own id") + XCTAssertFalse(a.getSession().isEmpty, "each shop is given a seance at construction") + XCTAssertFalse(b.getSession().isEmpty) + XCTAssertNotEqual(a.getSession(), b.getSession(), "each shop keeps its own seance — no cross-shop leak") + } + // MARK: - Session isolation (live backend) func test_two_shops_have_isolated_sessions() { diff --git a/DemoApp/Rees46DemoTests/PushTargetResolverTests.swift b/DemoApp/Rees46DemoTests/PushTargetResolverTests.swift deleted file mode 100644 index e7901852..00000000 --- a/DemoApp/Rees46DemoTests/PushTargetResolverTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// PushTargetResolverTests.swift -// REES46Tests -// -// Covers push routing (R4): which shop a push belongs to. Pure logic — mirror of the routing rules -// pinned on Android in `PushRoutingTest`. -// - -import XCTest -@testable import REES46 - -final class PushTargetResolverTests: XCTestCase { - - private func resolve(_ payloadShopId: String?, live: Set) -> String? { - PushTargetResolver.resolve(payloadShopId: payloadShopId, liveShopIds: live) - } - - func test_payload_shopId_matching_a_live_shop_routes_to_it() { - XCTAssertEqual(resolve("shop-b", live: ["shop-a", "shop-b"]), "shop-b") - } - - func test_payload_shopId_that_names_no_live_shop_is_dropped() { - XCTAssertNil(resolve("shop-x", live: ["shop-a", "shop-b"])) - } - - func test_no_shopId_with_a_single_live_shop_falls_back_to_it() { - XCTAssertEqual(resolve(nil, live: ["shop-a"]), "shop-a") - } - - func test_no_shopId_with_several_live_shops_is_dropped() { - XCTAssertNil(resolve(nil, live: ["shop-a", "shop-b"])) - } - - func test_no_shopId_with_no_live_shops_is_dropped() { - XCTAssertNil(resolve(nil, live: [])) - } - - func test_payload_shopId_matching_the_only_live_shop_routes_to_it() { - XCTAssertEqual(resolve("shop-a", live: ["shop-a"]), "shop-a") - } -} diff --git a/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift b/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift index 67c6c80e..d7abafa0 100644 --- a/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift +++ b/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift @@ -35,9 +35,18 @@ final class UpgradeMigrationTests: XCTestCase { private var standardSnapshot: [String: Any] = [:] private var partitionShops: [String] = [] private var keychainServices: [String] = [] + /// Real SDKs built by the assembled-upgrade tests, retained against the registry's weak reference. + private var retained: [PersonalizationSDK] = [] + /// A non-routable API domain so an assembled SDK's background `/init` never leaves the machine (the + /// upgrade assertions are synchronous and independent of it either way). + private let unroutableDomain = "invalid.rees46.test" override func setUp() { super.setUp() + // The multi-instance registry is process-global (the test host registers its own shop at + // launch); reset it so the assembled-SDK upgrade tests observe only the shop they build. + Rees46.reset() + SdkRegistry.shared.reset() // Snapshot then clear the legacy `.standard` keys so each test starts from a clean // pre-partition slate without disturbing the host app's real identity (restored in tearDown). standardSnapshot = [:] @@ -50,6 +59,9 @@ final class UpgradeMigrationTests: XCTestCase { } override func tearDown() { + Rees46.reset() + SdkRegistry.shared.reset() + retained.removeAll() for shop in partitionShops { UserDefaults.standard.removePersistentDomain(forName: StoragePartition.suiteName(for: shop)) } @@ -87,6 +99,16 @@ final class UpgradeMigrationTests: XCTestCase { private func blob(_ s: String) -> Data { Data(s.utf8) } + @discardableResult + private func retain(_ sdk: PersonalizationSDK) -> PersonalizationSDK { + retained.append(sdk) + return sdk + } + + private func same(_ lhs: PersonalizationSDK?, _ rhs: PersonalizationSDK?) -> Bool { + (lhs as AnyObject?) === (rhs as AnyObject?) + } + /// Seed a pre-partition install's identity in the real `.standard` domain. private func seedLegacyStandard(did: String, seance: String, idfa: String) { UserDefaults.standard.set(did, forKey: StoragePartition.deviceIdKey) @@ -171,4 +193,35 @@ final class UpgradeMigrationTests: XCTestCase { XCTAssertEqual(repo.advertisingId, "idfa-full") XCTAssertEqual(keychain.readInitData(), blob("did-full")) } + + // MARK: - The single-shop upgrade through the public SDK (assembled, no backend) + + /// The guarantee a single-shop host cares about most on upgrade: the code it already ships — + /// `createPersonalizationSDK(shopId:)` — keeps returning an SDK whose did is the pre-partition did, + /// so the user is not silently re-registered as a new device. This drives the real construction path + /// end-to-end (not just the repository), closing the gap where `repo.deviceId` was only *assumed* to + /// be what the SDK reads. Asserted synchronously, before the SDK's background `/init` runs, so it + /// needs no backend. (The seance is a rolling session, not permanent identity, and may roll on + /// upgrade — only the did is pinned here.) + func test_a_single_shop_upgrade_keeps_its_did_through_the_public_sdk() { + seedLegacyStandard(did: "did-upgrade", seance: "seance-upgrade", idfa: "idfa-upgrade") + + // Exactly the call an existing single-shop integration already ships. + let sdk = retain(createPersonalizationSDK(shopId: freshShop(), apiDomain: unroutableDomain)) + + XCTAssertEqual(sdk.getDeviceId(), "did-upgrade", + "an upgraded single-shop install must keep its did — no silent re-registration") + XCTAssertTrue(UserDefaults.standard.bool(forKey: StoragePartition.legacyConsumedFlag), + "the one-time legacy adoption ran through the real construction path") + } + + /// The upgraded single-shop install is also reachable through the new facade with no host change: + /// with exactly one live shop, `Rees46.instance()` resolves to that same instance. The legacy entry + /// point and the new API interoperate, so a host can adopt `Rees46` incrementally. + func test_an_upgraded_single_shop_is_reachable_through_the_new_facade() throws { + let sdk = retain(createPersonalizationSDK(shopId: freshShop(), apiDomain: unroutableDomain)) + + XCTAssertTrue(same(try Rees46.instance(), sdk), + "one shop built the legacy way resolves as the default via Rees46.instance()") + } } diff --git a/REES46/Classes/Sdk/Facade/Rees46.swift b/REES46/Classes/Sdk/Facade/Rees46.swift index 881e5a01..9c01859f 100644 --- a/REES46/Classes/Sdk/Facade/Rees46.swift +++ b/REES46/Classes/Sdk/Facade/Rees46.swift @@ -35,6 +35,12 @@ public enum Rees46 { /// Guards `pending`. The live instances themselves live in the (thread-safe) `SdkRegistry`. private static let lock = NSLock() + /// Fires the tracking beacon for a *pending* shop's push without constructing it — see `handlePush`. + /// Injectable so tests can assert the pending-track path offline; defaults to the real standalone + /// tracker (`PushDeliveryTracker`). + internal static var pendingPushTracker: (String, Rees46Config, PushEvent, String, String) -> Void + = PushDeliveryTracker.track + // MARK: - Initialization /** @@ -168,11 +174,17 @@ public enum Rees46 { // MARK: - Push /** - Routes a push to the instance it belongs to and tracks `event` for it. The target is the payload's - `shop_id`; with no `shop_id` a single-instance app still resolves, but an unknown shop — or an - absent `shop_id` while several shops are live — **drops** the push (via `PushTargetResolver`) - instead of tracking it against the wrong one. A payload that is not an SDK push (no resolvable - `type`/`code`) is ignored. Call this from a host that owns its messaging service. + Routes a push to the shop it belongs to and tracks `event` for it. The target is the payload's + `shop_id`; with no `shop_id` a single-shop app still resolves, but an unknown shop — or an absent + `shop_id` while several shops are registered — **drops** the push instead of tracking it against the + wrong one. A payload that is not an SDK push (no resolvable `type`/`code`) is ignored. Call this from + a host that owns its messaging service. + + Resolution matches `instance(for:)` — it considers LIVE and PENDING shops. A push addressed to a + registered-but-not-initialized (pending) shop is tracked **without** initializing it: the shop's + persisted did in its storage partition is all `track/` needs, so a lazily-registered shop's + push is tracked — not dropped — even when the process was launched cold by that push and only eager + shops are live (parity with Android's `materializeForPush` and RN's standalone `trackPush`). `delivered`/`received`/`clicked` track `track/delivered`/`track/received`/`track/clicked`. Navigation stays with the host: unlike the Android singleton, iOS routes click actions through the @@ -180,22 +192,33 @@ public enum Rees46 { method tracks but does not open the target screen. */ public static func handlePush(_ payload: [AnyHashable: Any], event: PushEvent) { - guard let shopId = PushTargetResolver.resolve( - payloadShopId: PushPayloadParser.shopId(from: payload), - liveShopIds: SdkRegistry.shared.shopIds() - ), let sdk = SdkRegistry.shared.byShopId(shopId) else { - return - } + let resolution = InstanceResolver.resolve( + requestedShopId: PushPayloadParser.shopId(from: payload), + liveShopIds: SdkRegistry.shared.shopIds(), + pendingShopIds: pendingShopIdsSnapshot() + ) + // Not an SDK push (no resolvable type/code) — ignore regardless of the target. guard let (type, code) = PushPayloadParser.typeAndCode(from: payload) else { return } - switch event { - case .delivered: - sdk.notificationDelivered(type: type, code: code) { _ in } - case .received: - sdk.notificationReceived(type: type, code: code) { _ in } - case .clicked: - sdk.notificationClicked(type: type, code: code) { _ in } + switch resolution { + case .existing(let shopId): + guard let sdk = SdkRegistry.shared.byShopId(shopId) else { return } + switch event { + case .delivered: + sdk.notificationDelivered(type: type, code: code) { _ in } + case .received: + sdk.notificationReceived(type: type, code: code) { _ in } + case .clicked: + sdk.notificationClicked(type: type, code: code) { _ in } + } + case .pending(let shopId): + // Registered but not initialized: track on its persisted identity, no construction/init — + // so a lazily-registered shop's push is tracked, not dropped. + guard let config = pendingConfig(for: shopId) else { return } + pendingPushTracker(shopId, config, event, type, code) + case .notRegistered, .ambiguous: + return // drop: unknown shop, or ambiguous (no shop_id while several are registered) } } @@ -223,17 +246,27 @@ public enum Rees46 { return Set(pending.keys) } + /// The stored config for a pending shop (its apiDomain/stream/storageKey), needed to track its push + /// standalone. `nil` if the shop was materialized (or dropped) in the meantime — then the push drops. + private static func pendingConfig(for shopId: String) -> Rees46Config? { + lock.lock() + defer { lock.unlock() } + return pending[shopId] + } + private static func registeredShopIds() -> [String] { SdkRegistry.shared.shopIds().union(pendingShopIdsSnapshot()).sorted() } // MARK: - Test hooks - /// Test-only: drops pending registrations. Live instances live in `SdkRegistry`. + /// Test-only: drops pending registrations and restores the real push tracker. Live instances live + /// in `SdkRegistry`. internal static func reset() { lock.lock() pending.removeAll() lock.unlock() + pendingPushTracker = PushDeliveryTracker.track } /// Test-only: shops registered lazily and not yet initialized. @@ -241,3 +274,53 @@ public enum Rees46 { pendingShopIdsSnapshot() } } + +/** + Fires a push tracking beacon for a shop WITHOUT constructing its SDK. A registered-but-pending shop + already has its did persisted in its storage partition — all `track/` needs — so a push for a + lazily-registered shop is tracked even when the process was launched cold by that push and only eager + shops are live. Standalone counterpart of an instance's `notification{Delivered,Received,Clicked}`; + mirror of RN's `trackPush`. iOS keeps the shop pending (the beacon needs no live instance), which also + sidesteps the weak `SdkRegistry` — a light-init instance would risk deallocating before the async POST + completes. + */ +internal enum PushDeliveryTracker { + + static func track( + shopId: String, + config: Rees46Config, + event: PushEvent, + type: String, + code: String + ) { + let did = StoragePartition + .store(for: shopId, storageKey: config.storageKey) + .string(forKey: StoragePartition.deviceIdKey) ?? "" + + let path: String + switch event { + case .delivered: path = "track/delivered" + case .received: path = "track/received" + case .clicked: path = "track/clicked" + } + + guard let url = URL(string: "https://" + config.apiDomain + "/" + path) else { return } + + // Same wire shape as SimplePersonalizationSDK.postRequest: a JSON body of stream + params. + let body: [String: Any] = [ + "stream": config.stream, + "shop_id": shopId, + "did": did, + "code": code, + "type": type, + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: request).resume() + } +} diff --git a/REES46/Classes/Sdk/Registry/PushTargetResolver.swift b/REES46/Classes/Sdk/Registry/PushTargetResolver.swift deleted file mode 100644 index 4f74ba31..00000000 --- a/REES46/Classes/Sdk/Registry/PushTargetResolver.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PushTargetResolver.swift -// REES46 -// -// Created by REES46 -// Copyright (c) 2023. All rights reserved. -// - -import Foundation - -/** - Pure decision behind push routing (R4): which shop a push belongs to. - - The payload's `shop_id` names the target; with no `shop_id`, a single-instance app still works (the - one live shop is used). A `shop_id` that names no live instance, or an absent `shop_id` while several - shops are live, resolves to `nil` — the push is dropped rather than delivered to the wrong shop. - Side-effect-free — mirror of the Android `PushTargetResolver`. - */ -internal enum PushTargetResolver { - - static func resolve(payloadShopId: String?, liveShopIds: Set) -> String? { - if let payloadShopId = payloadShopId { - return liveShopIds.contains(payloadShopId) ? payloadShopId : nil - } - return liveShopIds.count == 1 ? liveShopIds.first : nil - } -} diff --git a/REES46/Classes/Sdk/Registry/SdkRegistry.swift b/REES46/Classes/Sdk/Registry/SdkRegistry.swift index 6e850170..e29c6549 100644 --- a/REES46/Classes/Sdk/Registry/SdkRegistry.swift +++ b/REES46/Classes/Sdk/Registry/SdkRegistry.swift @@ -15,7 +15,8 @@ import Foundation surface: it owns the routing state — an ordered fan-out set of live instances plus a "current" default pointer — so the single-instance behaviour is unchanged while the plumbing for resolving an instance by `shop_id` is in place and tested. The public `Rees46.initialize/instance(for:)/register` - API is layered on top later (R3), and push routing (R4) consumes `PushTargetResolver` + `all()`. + API is layered on top later (R3), and push routing (R4) resolves the target via `InstanceResolver` + (live + pending) and fans a refreshed token out over `all()`. Mirror of the Android `SdkRegistry`, with one deliberate iOS difference: **references are weak.** On Android the SDK is a long-lived process singleton held by the JVM; on iOS the host owns the SDK's