diff --git a/DemoApp/REES46.xcodeproj/project.pbxproj b/DemoApp/REES46.xcodeproj/project.pbxproj index 1545dea3..08c8bf0d 100644 --- a/DemoApp/REES46.xcodeproj/project.pbxproj +++ b/DemoApp/REES46.xcodeproj/project.pbxproj @@ -8,22 +8,26 @@ /* 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 */; }; + 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 */; }; 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 */; }; + 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 */; }; 9A7A95E325BC40E900FFA40A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7A95E225BC40E900FFA40A /* NotificationViewController.swift */; }; @@ -37,6 +41,11 @@ 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 */; }; + 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 */; }; @@ -45,11 +54,19 @@ 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 */; }; 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 */ /* Begin PBXContainerItemProxy section */ @@ -93,9 +110,13 @@ /* 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; }; + 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; }; 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 = ""; }; @@ -105,18 +126,20 @@ 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 = ""; }; + 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 = ""; }; 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 = ""; }; + 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; }; @@ -136,8 +159,17 @@ 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; }; + 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 = ""; }; D0763E592DB8F5170013E367 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D0B70C162D522DAF0001782A /* SearchServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceTests.swift; sourceTree = ""; }; @@ -146,11 +178,13 @@ 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 = ""; }; 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 */ @@ -253,6 +287,7 @@ 607FACDC1AFB9204008FA782 /* Images.xcassets */, 9AFB9EE328044E4D001462AC /* PushPresent */, 607FACD31AFB9204008FA782 /* Supporting Files */, + 661B8BEEE8EEBD5EA66BDEE8 /* MultiInstanceViewController.swift */, ); name = Rees46Demo; path = REES46Demo; @@ -274,6 +309,22 @@ D0763E592DB8F5170013E367 /* Constants.swift */, 75E2378B2C334A4200119197 /* services */, 607FACE91AFB9204008FA782 /* Supporting Files */, + AB3ACDA56DB305BC47811CEC /* FakeSDK.swift */, + B5038AC43E9130E56F51F7FB /* InstanceResolverTests.swift */, + 6493F884A898C533B4817F97 /* SdkRegistryTests.swift */, + 50CF8287AA77C0C1D6CD5046 /* StoragePartitionTests.swift */, + 70D7769B0D5C13182C6C9391 /* StorageMigrationTests.swift */, + 86C33241CE9B2B9CA3CD5A9B /* UserIdentityRepositoryTests.swift */, + 8237EEC8810D8D4E8706C65B /* UserIdentityRepositoryIsolationTests.swift */, + 5B55388A3AAFA52F88B08E3E /* KeychainPartitionTests.swift */, + CD265D9DC0347C2031552262 /* KeychainInitStoreTests.swift */, + F1F1E786F1F3C200D5DFE5D4 /* SessionManagerTests.swift */, + 56F59CB601E63A49FB18F815 /* LocalStateRepositoryTests.swift */, + B6E945ACB56ED3FB898E8A42 /* UpgradeMigrationTests.swift */, + 2EE37E1F7C2A166E0C3DFE89 /* Rees46FacadeTests.swift */, + B5AD555A428611EB01D95215 /* PushPayloadParserTests.swift */, + 732B3CD050F3471973F52C74 /* HandlePushRoutingTests.swift */, + 8FDDEEF9BF2D37D8410CBFA2 /* MultiInstanceE2ETest.swift */, ); path = Rees46DemoTests; sourceTree = ""; @@ -681,6 +732,7 @@ E9CF44412B30809C000F53F1 /* SearchViewController.swift in Sources */, 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */, 758714A82C6CF1EB008E5E4E /* AppEnvironments.struct.swift in Sources */, + 447EFA0789368C931564CA42 /* MultiInstanceViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -704,6 +756,22 @@ 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 */, + 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 */, + 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 */, + 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/FakeSDK.swift b/DemoApp/Rees46DemoTests/FakeSDK.swift new file mode 100644 index 00000000..457e3390 --- /dev/null +++ b/DemoApp/Rees46DemoTests/FakeSDK.swift @@ -0,0 +1,95 @@ +// +// 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) + + /// 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)] = [] + + 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) { + clickedTracks.append((type, code)) + } + 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)) + } + 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/HandlePushRoutingTests.swift b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift new file mode 100644 index 00000000..c7a24bae --- /dev/null +++ b/DemoApp/Rees46DemoTests/HandlePushRoutingTests.swift @@ -0,0 +1,195 @@ +// +// 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_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") + + 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") + } + + // 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/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/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/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/MultiInstanceE2ETest.swift b/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift new file mode 100644 index 00000000..d7e71f57 --- /dev/null +++ b/DemoApp/Rees46DemoTests/MultiInstanceE2ETest.swift @@ -0,0 +1,192 @@ +// +// 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. 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 +@testable import REES46 + +final class MultiInstanceE2ETest: XCTestCase { + + private let shopA = Constants.testShopId + 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() + SdkRegistry.shared.reset() + } + + 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, + 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: - 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() { + 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()) + } +} 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/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/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/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/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/UpgradeMigrationTests.swift b/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift new file mode 100644 index 00000000..d7abafa0 --- /dev/null +++ b/DemoApp/Rees46DemoTests/UpgradeMigrationTests.swift @@ -0,0 +1,227 @@ +// +// 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] = [] + /// 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 = [:] + for key in standardKeys { + if let value = UserDefaults.standard.object(forKey: key) { + standardSnapshot[key] = value + } + UserDefaults.standard.removeObject(forKey: key) + } + } + + override func tearDown() { + Rees46.reset() + SdkRegistry.shared.reset() + retained.removeAll() + 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) } + + @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) + 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")) + } + + // 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/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..0eea29d5 --- /dev/null +++ b/DemoApp/Rees46DemoTests/UserIdentityRepositoryTests.swift @@ -0,0 +1,117 @@ +// +// 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 seanceLastActTime: Date? + var advertisingId: String? + func removeDeviceId() { deviceId = nil } + func clearIdentity() { deviceId = nil; seance = nil; seanceLastActTime = 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_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" + 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/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/Notifications/NotificationService.swift b/REES46/Classes/Notifications/NotificationService.swift index 3e8ac6e3..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, @@ -205,38 +213,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]? { @@ -257,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/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/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 new file mode 100644 index 00000000..9c01859f --- /dev/null +++ b/REES46/Classes/Sdk/Facade/Rees46.swift @@ -0,0 +1,326 @@ +// +// 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() + + /// 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 + + /** + 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: - Push + + /** + 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 + 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) { + 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 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) + } + } + + // 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) + } + + /// 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 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. + internal static func pendingShopIds() -> Set { + 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/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/Model/PushEvent.enum.swift b/REES46/Classes/Sdk/Model/PushEvent.enum.swift new file mode 100644 index 00000000..c6dc30c8 --- /dev/null +++ b/REES46/Classes/Sdk/Model/PushEvent.enum.swift @@ -0,0 +1,29 @@ +// +// 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:)`. + + - `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`). + + 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 +} diff --git a/REES46/Classes/Sdk/Registry/Cancellable.swift b/REES46/Classes/Sdk/Registry/Cancellable.swift new file mode 100644 index 00000000..f4b23b99 --- /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. 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. + */ +public final class Cancellable { + + /// A no-op handle for subscriptions that resolved synchronously and hold nothing. + public static let noop = Cancellable {} + + private let onCancel: () -> Void + private let lock = NSLock() + private var cancelled = false + + init(_ onCancel: @escaping () -> Void) { + self.onCancel = onCancel + } + + public 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/SdkRegistry.swift b/REES46/Classes/Sdk/Registry/SdkRegistry.swift new file mode 100644 index 00000000..e29c6549 --- /dev/null +++ b/REES46/Classes/Sdk/Registry/SdkRegistry.swift @@ -0,0 +1,194 @@ +// +// 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) 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 + 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/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.. 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/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/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..3672d081 --- /dev/null +++ b/REES46/Classes/Sdk/Storage/StoragePartition.swift @@ -0,0 +1,66 @@ +// +// 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 seanceLastActKey = "seance_last_act" + 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..ba3a33db --- /dev/null +++ b/REES46/Classes/Sdk/Storage/UserIdentityRepository.swift @@ -0,0 +1,44 @@ +// +// 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 } + + /// When the session was last active — the anchor for the rolling session TTL. Nil when no session + /// has been recorded yet. + var seanceLastActTime: Date? { 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..dc94a9ef --- /dev/null +++ b/REES46/Classes/Sdk/Storage/UserIdentityRepositoryImpl.swift @@ -0,0 +1,80 @@ +// +// 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 seanceLastActTime: Date? { + get { + guard let seconds = store.object(forKey: StoragePartition.seanceLastActKey) as? Double else { + return nil + } + return Date(timeIntervalSince1970: seconds) + } + set { + if let newValue = newValue { + store.set(newValue.timeIntervalSince1970, forKey: StoragePartition.seanceLastActKey) + } else { + store.removeObject(forKey: StoragePartition.seanceLastActKey) + } + } + } + + 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) + store.removeObject(forKey: StoragePartition.seanceLastActKey) + } +} diff --git a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift index be0644ca..c84705f0 100644 --- a/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift +++ b/REES46/Classes/Sdk/impl/SimplePersonalizationSDK.swift @@ -31,6 +31,23 @@ 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 + + /// 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 + + /// 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 @@ -101,9 +118,33 @@ class SimplePersonalizationSDK: PersonalizationSDK { parentViewController: UIViewController? = nil, enableAutoPopupPresentation: Bool = true, needReInitialization: Bool = false, + storageKey: String? = nil, + userIdentity: UserIdentityRepository? = nil, + keychainStore: KeychainInitStore? = nil, + sessionManager: SessionManager? = nil, + localState: LocalStateRepository? = 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. + 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" + 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 @@ -118,12 +159,13 @@ 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) - deviceId = UserDefaults.standard.string(forKey: SdkConstants.deviceIdKey) ?? "" + deviceId = self.userIdentity.deviceId ?? "" urlSession = URLSession.shared sessionQueue.addOperation { @@ -131,7 +173,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 { @@ -1173,15 +1216,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 +1251,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") + // Session is client-owned — do not overwrite it with the server-assigned seance. sleep(1) completion(.success(resultResponse)) } else { @@ -1223,10 +1266,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 } @@ -1288,17 +1328,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 @@ -1321,9 +1361,21 @@ 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") + + 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`. + 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) { @@ -1349,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)) @@ -1360,7 +1412,7 @@ class SimplePersonalizationSDK: PersonalizationSDK { func storeSuccessInit(result: InitResponse) { persistDeviceIdIfAbsent(result.deviceId) - UserDefaults.standard.set(result.seance, forKey: "seance_id") + // Session is client-owned (see SessionManager) — the server seance is not persisted here. } private static let deviceIdPersistLock = NSObject() @@ -1374,9 +1426,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 } } @@ -1431,7 +1483,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) @@ -1528,7 +1580,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() diff --git a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift index 314805ee..7d65c4f2 100644 --- a/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift +++ b/REES46/Classes/Sdk/protocol/PersonalizationSDK.protocol.swift @@ -295,84 +295,34 @@ 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() } } +/** + 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, @@ -386,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, @@ -401,10 +394,11 @@ public func createPersonalizationSDK( parentViewController: parentViewController, enableAutoPopupPresentation: enableAutoPopupPresentation, needReInitialization: needReInitialization, + storageKey: storageKey, completion: completion ) - + 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 ebee5fb5..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 { @@ -216,15 +247,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 +310,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) } }