diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 369cf72c4..fbbde5b25 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -7,30 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */; }; - F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */; }; - 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */; }; - 23335221BB1D4F8C8D5C864D /* InAppWebViewPrewarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */; }; - 23E4A509BFE94104BD01B61A /* MindboxWebBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */; }; - AE190755CA97473B83E1568D /* MBContainerConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */; }; - 3FDBA6E4D39A49369A335CF4 /* SDKUserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */; }; - 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */; }; - 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */; }; - B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */; }; - 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */; }; - 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */; }; - 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */; }; - B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */; }; - 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */; }; - 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */; }; - CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */; }; - C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */; }; - 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68184C936B4243C28CC10829 /* SDKUserAgent.swift */; }; 0A3D045A2BC6803E00E1FC52 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */; }; 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; + 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; + 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */; }; 1E28BAF922E64B9CB6E22A0B /* DateFormatMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */; }; + 1E316CC518D9111F0BD25590 /* InAppMessagesTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; + 23335221BB1D4F8C8D5C864D /* InAppWebViewPrewarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */; }; + 23E4A509BFE94104BD01B61A /* MindboxWebBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */; }; + 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */; }; + 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; @@ -119,7 +107,8 @@ 33E42E5C268323E60046CBCB /* CashdeskRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E42E5B268323E60046CBCB /* CashdeskRequest.swift */; }; 33EBF0B0264E6283002A35D5 /* MBSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33EBF0AF264E6283002A35D5 /* MBSessionManager.swift */; }; 3FB93AC126964AD3A061B4A7 /* FeatureToggleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3456BAC56F984378B6CED7CB /* FeatureToggleManager.swift */; }; - B202CDC049DDB7A2F9833ADF /* InAppTagsGating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4000B12B31961251F6C7 /* InAppTagsGating.swift */; }; + 3FDBA6E4D39A49369A335CF4 /* SDKUserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */; }; + 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68184C936B4243C28CC10829 /* SDKUserAgent.swift */; }; 472179A72C80755A00C15E7F /* ShownInAppsIDsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472179A62C80755A00C15E7F /* ShownInAppsIDsMigration.swift */; }; 472765522E0D52A500A5A060 /* RemoveBackgroundTaskDataMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472765512E0D52A500A5A060 /* RemoveBackgroundTaskDataMigration.swift */; }; 472F549E2C6E272A0008C465 /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472F549D2C6E272A0008C465 /* MBPushNotification.swift */; }; @@ -179,8 +168,6 @@ 4731A81A2F447C3100CBE1E5 /* ConfigMonitoringError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A79D2F447C3100CBE1E5 /* ConfigMonitoringError.json */; }; 4731A81B2F447C3100CBE1E5 /* ConfigSettingsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7A02F447C3100CBE1E5 /* ConfigSettingsTypeError.json */; }; 4731A81C2F447C3100CBE1E5 /* MonitoringConfig.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */; }; - AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; - 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 4731A81D2F447C3100CBE1E5 /* InAppFrequencyError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7AA2F447C3100CBE1E5 /* InAppFrequencyError.json */; }; 4731A81E2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */; }; 4731A81F2F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7E82F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json */; }; @@ -265,7 +252,9 @@ 47FDF0BA2C5BDAB80051F08C /* MigrationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */; }; 47FDF0BC2C5BE8BB0051F08C /* MigrationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */; }; 4841683F6839440F84477966 /* OperationNameValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */; }; + 57A1B3230000000000000007 /* (null) in Sources */ = {isa = PBXBuildFile; }; 5D3FCB95C2AF59DF36A61254 /* WebViewLocalStateStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */; }; + 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 6182078DDFC681D168546DAD /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4008 /* HapticService.swift */; }; 6182078DDFC681D168546DAE /* HapticRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4009 /* HapticRequest.swift */; }; 6182078DDFC681D168546DAF /* HapticRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED400A /* HapticRequestParser.swift */; }; @@ -296,6 +285,12 @@ 6FDD1463266F7CED00A50C35 /* ProductElementReponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDD1462266F7CED00A50C35 /* ProductElementReponse.swift */; }; 6FDD1465266F7CFE00A50C35 /* ItemProductResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDD1464266F7CFE00A50C35 /* ItemProductResponse.swift */; }; 7764BA10E25046CEA7035ED8 /* DateFormatMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8755088A5E704C65A1C3F6DB /* DateFormatMigration.swift */; }; + 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */; }; + 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */; }; + 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */; }; + 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */; }; + 7A3F1B2C9D4E5F60718293A5 /* TransparentViewJSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */; }; + 7A3F1B2C9D4E5F60718293A7 /* Tags-FailedTargeting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */; }; 813FE960DC0543F681C94275 /* SettingsRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FC619AC23245CC8CD45E63 /* SettingsRequestParser.swift */; }; 840042A12614CE0000CA17C5 /* ClickNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840042A02614CE0000CA17C5 /* ClickNotificationManager.swift */; }; 840C387225CC1AF200D50183 /* CDEvent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840C387025CC1AF200D50183 /* CDEvent+CoreDataClass.swift */; }; @@ -353,6 +348,8 @@ 84FCD3B525CA0FD300D1E574 /* MockPersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCD3B425CA0FD300D1E574 /* MockPersistenceStorage.swift */; }; 84FCD3B925CA109E00D1E574 /* MockNetworkFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */; }; 84FCD3BD25CA10F600D1E574 /* SuccessResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */; }; + 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */; }; + 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; 9B24FAAC28C74B8300F10B5D /* InAppConfigurationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */; }; 9B24FAAE28C74BA500F10B5D /* InAppCoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */; }; 9B24FAB128C74BD200F10B5D /* InAppConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */; }; @@ -365,6 +362,7 @@ 9B4F9DF928D088A9002C9CF0 /* InAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9DF628D088A9002C9CF0 /* InAppConfig.swift */; }; 9B4F9E0528D0945F002C9CF0 /* InAppCoreManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9E0428D0945F002C9CF0 /* InAppCoreManagerMock.swift */; }; 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B52570628D1AF880029B1BC /* InAppPresentationManagerMock.swift */; }; + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; 9B9C95312921116F00BB29DA /* UUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C952F2921116F00BB29DA /* UUIDDebugService.swift */; }; 9B9C95322921116F00BB29DA /* PasteboardUUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C95302921116F00BB29DA /* PasteboardUUIDDebugService.swift */; }; 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C9534292111A700BB29DA /* MockUUIDDebugService.swift */; }; @@ -385,12 +383,10 @@ A153E03F29BB002A003C34D4 /* SessionTemporaryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */; }; A153E04129BB0A8B003C34D4 /* InAppConfigurationWithOperations.json in Resources */ = {isa = PBXBuildFile; fileRef = A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */; }; A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */; }; - 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; A154E330299E0F1600F8F074 /* InAppGeoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */; }; A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E333299E110E00F8F074 /* EventRepositoryMock.swift */; }; A15D701629AF810E007131E7 /* SDKLogsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */; }; A15D701A29AF8142007131E7 /* SDKLogsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E382299E5B7500F8F074 /* SDKLogsManager.swift */; }; - 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; A15D704729AF81DC007131E7 /* MBLoggerCoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E34F299E5B6D00F8F074 /* MBLoggerCoreDataManager.swift */; }; A170EDDC29B0883800CE547F /* MindboxLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17853BE29AF7E940072578F /* MindboxLogger.framework */; }; A170EDE229B08A2700CE547F /* MindboxLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = A170EDE129B08A2700CE547F /* MindboxLogger.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -457,8 +453,12 @@ A1D017F22976CC9400CD9F99 /* SegmentTargeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */; }; A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; + A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */; }; + AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; + AB0E83C770AAB9A2DC8E9BF5 /* JSONValueTagsMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */; }; + AE190755CA97473B83E1568D /* MBContainerConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */; }; AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */; }; - 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; + B202CDC049DDB7A2F9833ADF /* InAppTagsGating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4000B12B31961251F6C7 /* InAppTagsGating.swift */; }; B36D57852696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36D57842696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift */; }; B3A6254C2689F83100B6A3B7 /* PersonalOffersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254B2689F83100B6A3B7 /* PersonalOffersResponse.swift */; }; B3A625502689F8B600B6A3B7 /* BenefitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254F2689F8B600B6A3B7 /* BenefitResponse.swift */; }; @@ -475,11 +475,15 @@ B4E438702D8AFA5700603F3A /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E4386E2D8AFA5700603F3A /* WebViewController.swift */; }; B4E438722D8AFA6700603F3A /* WebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E438712D8AFA6700603F3A /* WebViewFactory.swift */; }; B4E438742D8AFAA700603F3A /* WebviewPresentationStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E438732D8AFAA700603F3A /* WebviewPresentationStrategy.swift */; }; + B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */; }; B7705CA22E0A1F0000C0FFEE /* AppGroupUnavailableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7705CA12E0A1F0000C0FFEE /* AppGroupUnavailableTests.swift */; }; + B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */; }; BB4D7CC72BDEC51D008E3AB8 /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4D7CC62BDEC51D008E3AB8 /* Notification+Extensions.swift */; }; BB6563102BE3BA430090C473 /* UIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */; }; BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAAC17B2BB2FC9100E1E25E /* MockEvent.swift */; }; C0360B4CF720E2CBDC488E82 /* TrackVisitManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */; }; + C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */; }; + CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */; }; D216DE512C0716B70020F58A /* StringExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D216DE502C0716B70020F58A /* StringExtensionsTests.swift */; }; D216DE532C0716B80020F58A /* TimeIntervalTimeSpanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */; }; D2F7E2432BADB89900B24BB8 /* UserVisitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F7E2412BADB89900B24BB8 /* UserVisitManager.swift */; }; @@ -580,17 +584,16 @@ F34A103D2F455B840065392A /* FeatureTogglesConfigParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A103A2F455B840065392A /* FeatureTogglesConfigParsingTests.swift */; }; F34A10442F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */; }; F34A10452F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103F2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json */; }; - F34A104B2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */; }; - F34A104C2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */; }; F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; + F34A104B2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */; }; + F34A104C2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */; }; F34A45AE2B7628B700634C8B /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AD2B7628B700634C8B /* MBPushNotification.swift */; }; F34A45B02B762A6100634C8B /* MindboxPushValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */; }; F351F1C02CE380A40053423E /* InappMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1BF2CE380A40053423E /* InappMapper.swift */; }; F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1C12CE5F23A0053423E /* InappMapperTests.swift */; }; F351F1C42CE60CA90053423E /* 1-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C32CE60CA90053423E /* 1-Targeting.json */; }; - 7A3F1B2C9D4E5F60718293A7 /* Tags-FailedTargeting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */; }; F351F1C62CE626450053423E /* 15-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C52CE626450053423E /* 15-Targeting.json */; }; F351F1C82CE72B300053423E /* 44-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C72CE72B300053423E /* 44-Targeting.json */; }; F351F1CB2CE72D460053423E /* 46-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1CA2CE72D460053423E /* 46-Targeting.json */; }; @@ -609,6 +612,7 @@ F382F20D2BAC548900BC97FF /* VisitTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382F20C2BAC548900BC97FF /* VisitTargetingChecker.swift */; }; F382F2112BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382F2102BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift */; }; F38562FF2DB66CB600D91208 /* DictionaryKeyValueModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38562FE2DB66CB600D91208 /* DictionaryKeyValueModel.swift */; }; + F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */; }; F39116EE2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116ED2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift */; }; F39116F52AA9AF7A00852298 /* InappFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116F42AA9AF7A00852298 /* InappFilter.swift */; }; F39116F82AA9B04E00852298 /* VariantsFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116F72AA9B04E00852298 /* VariantsFilter.swift */; }; @@ -668,6 +672,7 @@ F3A8B9A92A3A713E00E9C055 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A8B9A82A3A713E00E9C055 /* SettingsModel.swift */; }; F3A8B9AB2A3A719C00E9C055 /* ABTestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A8B9AA2A3A719C00E9C055 /* ABTestModel.swift */; }; F3A961D62DE9C5220016D5D3 /* InAppPresentationValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A961D52DE9C5220016D5D3 /* InAppPresentationValidatorTests.swift */; }; + F3A97FFC302C707F005EA1AF /* InjectEmbeddedBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A97FFB302C707F005EA1AF /* InjectEmbeddedBlocks.swift */; }; F3AF0AD02BC40FCF0063BA58 /* InappTTLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3AF0ACF2BC40FCF0063BA58 /* InappTTLTests.swift */; }; F3B2A3982A4C79D900E2CA25 /* MindboxLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B2A3972A4C79D900E2CA25 /* MindboxLogger.swift */; }; F3B2A39E2A4C79E200E2CA25 /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B2A3992A4C79E200E2CA25 /* Status.swift */; }; @@ -690,10 +695,6 @@ F3C1A0022F5B100100ABC001 /* InappShowFailureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */; }; F3C1A0042F5B100100ABC001 /* InAppShowFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */; }; F3C1A0062F5B100100ABC001 /* InappShowFailureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */; }; - FA39EE97042009DCEC24E971 /* InAppTagsGatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */; }; - 1E316CC518D9111F0BD25590 /* InAppMessagesTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */; }; - AB0E83C770AAB9A2DC8E9BF5 /* JSONValueTagsMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */; }; - 7A3F1B2C9D4E5F60718293A5 /* TransparentViewJSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */; }; F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; @@ -731,6 +732,7 @@ F3FEEAAB2C25D874000E9D0F /* InjectReplaceable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */; }; F3FEEAAD2C25FD1F000E9D0F /* InjectABTestUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */; }; F78E92EF282E63320003B4A3 /* DispatchSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */; }; + FA39EE97042009DCEC24E971 /* InAppTagsGatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -765,29 +767,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewLearnedHostsStore.swift; sourceTree = ""; }; - DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmPlanner.swift; sourceTree = ""; }; - 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmNavigationPolicy.swift; sourceTree = ""; }; - 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmService.swift; sourceTree = ""; }; - 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MindboxWebBridgeTests.swift; sourceTree = ""; }; - 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBContainerConcurrencyTests.swift; sourceTree = ""; }; - 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; - 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewCacheTests.swift; sourceTree = ""; }; - FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewFactory.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPError.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicy.swift; sourceTree = ""; }; - A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTMLFetcher.swift; sourceTree = ""; }; - 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; - 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewTimeoutErrorTests.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPErrorTests.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; - 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; - F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; - 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; + 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackVisitManagerTests.swift; sourceTree = ""; }; 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewLocalStateStorageTests.swift; sourceTree = ""; }; 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationNameValidator.swift; sourceTree = ""; }; + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; + 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewTimeoutErrorTests.swift; sourceTree = ""; }; + 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewLearnedHostsStore.swift; sourceTree = ""; }; + 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmService.swift; sourceTree = ""; }; + 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; + 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; 30FC619AC23245CC8CD45E63 /* SettingsRequestParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsRequestParser.swift; sourceTree = ""; }; 313B233025ADEA0F00A1CB72 /* Mindbox.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Mindbox.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 313B233325ADEA0F00A1CB72 /* Mindbox.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Mindbox.h; sourceTree = ""; }; @@ -931,8 +921,6 @@ 4731A7B62F447C3100CBE1E5 /* InAppTargetingError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingError.json; sourceTree = ""; }; 4731A7B72F447C3100CBE1E5 /* InAppTargetingTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingTypeError.json; sourceTree = ""; }; 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringConfig.json; sourceTree = ""; }; - EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; - 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsElementsMixedError.json; sourceTree = ""; }; 4731A7BC2F447C3100CBE1E5 /* MonitoringLogsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsError.json; sourceTree = ""; }; 4731A7BD2F447C3100CBE1E5 /* MonitoringLogsOneElementError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOneElementError.json; sourceTree = ""; }; @@ -1024,7 +1012,14 @@ 47EFF0FC2E8D85B700E72D0A /* DatabaseMetadataMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseMetadataMigrationTests.swift; sourceTree = ""; }; 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerProtocol.swift; sourceTree = ""; }; 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationProtocol.swift; sourceTree = ""; }; + 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBContainerConcurrencyTests.swift; sourceTree = ""; }; 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransparentViewSyncOperationResponseTests.swift; sourceTree = ""; }; + 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; + 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; + 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; + 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; + 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; + 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; 6F1EAA15266A670E007A335B /* ProductListItemsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItemsResponse.swift; sourceTree = ""; }; 6FDD143A266F7BD900A50C35 /* ProcessingStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessingStatusResponse.swift; sourceTree = ""; }; 6FDD143C266F7BEB00A50C35 /* ItemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemResponse.swift; sourceTree = ""; }; @@ -1048,6 +1043,12 @@ 6FDD1460266F7CE300A50C35 /* DiscountAmountTypeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscountAmountTypeResponse.swift; sourceTree = ""; }; 6FDD1462266F7CED00A50C35 /* ProductElementReponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductElementReponse.swift; sourceTree = ""; }; 6FDD1464266F7CFE00A50C35 /* ItemProductResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProductResponse.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPError.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicy.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPErrorTests.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; + 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewJSBridgeTests.swift; sourceTree = ""; }; + 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Tags-FailedTargeting.json"; sourceTree = ""; }; 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceResolvePositionTests.swift; sourceTree = ""; }; 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceShakeToEditTests.swift; sourceTree = ""; }; 840042A02614CE0000CA17C5 /* ClickNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickNotificationManager.swift; sourceTree = ""; }; @@ -1106,6 +1107,8 @@ 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkFetcher.swift; sourceTree = ""; }; 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 8755088A5E704C65A1C3F6DB /* DateFormatMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigration.swift; sourceTree = ""; }; + 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewCacheTests.swift; sourceTree = ""; }; + 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmNavigationPolicy.swift; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; @@ -1131,13 +1134,13 @@ 9BC24E7228F6953D00C2619C /* InAppConfigurationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppConfigurationAPI.swift; sourceTree = ""; }; 9BC24E7328F6953D00C2619C /* ConfigResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigResponse.swift; sourceTree = ""; }; 9BC24E7928F6C08700C2619C /* InAppConfiguration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppConfiguration.json; sourceTree = ""; }; + 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MindboxWebBridgeTests.swift; sourceTree = ""; }; A11FBE8829DD76BF00F5FB7B /* InAppMessagesEventSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesEventSender.swift; sourceTree = ""; }; A153E03A29BAFE01003C34D4 /* CustomOperationTargeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOperationTargeting.swift; sourceTree = ""; }; A153E03C29BAFEC0003C34D4 /* CustomOperationChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOperationChecker.swift; sourceTree = ""; }; A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTemporaryStorage.swift; sourceTree = ""; }; A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = InAppConfigurationWithOperations.json; sourceTree = ""; }; A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKLogManagerTests.swift; sourceTree = ""; }; - 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppGeoResponse.swift; sourceTree = ""; }; A154E333299E110E00F8F074 /* EventRepositoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRepositoryMock.swift; sourceTree = ""; }; A154E33A299E5B6D00F8F074 /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; @@ -1158,7 +1161,6 @@ A154E37F299E5B7500F8F074 /* SDKLogsStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsStatus.swift; sourceTree = ""; }; A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsRequest.swift; sourceTree = ""; }; A154E382299E5B7500F8F074 /* SDKLogsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsManager.swift; sourceTree = ""; }; - 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; A170EDE129B08A2700CE547F /* MindboxLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MindboxLogger.h; sourceTree = ""; }; A17853BE29AF7E940072578F /* MindboxLogger.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MindboxLogger.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A17853C529AF7E950072578F /* MindboxLoggerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MindboxLoggerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1209,6 +1211,7 @@ A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentTargeting.swift; sourceTree = ""; }; A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTargetingChecker.swift; sourceTree = ""; }; A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppProductSegmentResponse.swift; sourceTree = ""; }; + A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTMLFetcher.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA01 /* FirstInitializationDateTimeRuntimeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeRuntimeTests.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA02 /* FirstInitializationDateTimeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeMigrationTests.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA11 /* DeviceUUIDInitializationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceUUIDInitializationTests.swift; sourceTree = ""; }; @@ -1242,7 +1245,6 @@ BD1BE43AA9EAEA03F8ED400C /* HapticRequestParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestParserTests.swift; sourceTree = ""; }; BD1BE43AA9EAEA03F8ED400D /* HapticRequestValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidatorTests.swift; sourceTree = ""; }; BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBEventRepositorySendRawTests.swift; sourceTree = ""; }; - 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigrationTests.swift; sourceTree = ""; }; D216DE502C0716B70020F58A /* StringExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeIntervalTimeSpanTests.swift; sourceTree = ""; }; @@ -1250,7 +1252,10 @@ D2F7E2462BADB9EF00B24BB8 /* UserVisitManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserVisitManagerTests.swift; sourceTree = ""; }; D2F7E2492BADC2AB00B24BB8 /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; D2F7E24B2BADC4CA00B24BB8 /* MockSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionManager.swift; sourceTree = ""; }; + DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmPlanner.swift; sourceTree = ""; }; + EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceBehaviorTests.swift; sourceTree = ""; }; + F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; F30005432CFF3F7D004BE915 /* ABTestStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestStubs.swift; sourceTree = ""; }; F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequencyTests.swift; sourceTree = ""; }; F30654BA2F1A83520058808C /* MindboxWebViewFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxWebViewFacade.swift; sourceTree = ""; }; @@ -1343,17 +1348,16 @@ F34A103B2F455B840065392A /* SettingsConfigParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsConfigParsingTests.swift; sourceTree = ""; }; F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesError.json; sourceTree = ""; }; F34A103F2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json; sourceTree = ""; }; - F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsFalse.json; sourceTree = ""; }; - F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsTypeError.json; sourceTree = ""; }; F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; + F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsFalse.json; sourceTree = ""; }; + F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsTypeError.json; sourceTree = ""; }; F34A45AD2B7628B700634C8B /* MBPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBPushNotification.swift; sourceTree = ""; }; F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxPushValidator.swift; sourceTree = ""; }; F351F1BF2CE380A40053423E /* InappMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapper.swift; sourceTree = ""; }; F351F1C12CE5F23A0053423E /* InappMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapperTests.swift; sourceTree = ""; }; F351F1C32CE60CA90053423E /* 1-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "1-Targeting.json"; sourceTree = ""; }; - 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Tags-FailedTargeting.json"; sourceTree = ""; }; F351F1C52CE626450053423E /* 15-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "15-Targeting.json"; sourceTree = ""; }; F351F1C72CE72B300053423E /* 44-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "44-Targeting.json"; sourceTree = ""; }; F351F1C92CE72D460053423E /* 45-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "45-Targeting.json"; sourceTree = ""; }; @@ -1431,6 +1435,7 @@ F3A8B9A82A3A713E00E9C055 /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = ""; }; F3A8B9AA2A3A719C00E9C055 /* ABTestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestModel.swift; sourceTree = ""; }; F3A961D52DE9C5220016D5D3 /* InAppPresentationValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPresentationValidatorTests.swift; sourceTree = ""; }; + F3A97FFB302C707F005EA1AF /* InjectEmbeddedBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectEmbeddedBlocks.swift; sourceTree = ""; }; F3AF0ACF2BC40FCF0063BA58 /* InappTTLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappTTLTests.swift; sourceTree = ""; }; F3B2A3972A4C79D900E2CA25 /* MindboxLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MindboxLogger.swift; sourceTree = ""; }; F3B2A3992A4C79E200E2CA25 /* Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; @@ -1453,10 +1458,6 @@ F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManager.swift; sourceTree = ""; }; F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppShowFailure.swift; sourceTree = ""; }; F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManagerTests.swift; sourceTree = ""; }; - 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; - F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesTrackerTests.swift; sourceTree = ""; }; - 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; - 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewJSBridgeTests.swift; sourceTree = ""; }; F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; @@ -1494,17 +1495,21 @@ F3FEEAA82C25CC9F000E9D0F /* InjectionMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectionMocks.swift; sourceTree = ""; }; F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectReplaceable.swift; sourceTree = ""; }; F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectABTestUtilities.swift; sourceTree = ""; }; + F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesTrackerTests.swift; sourceTree = ""; }; F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchSemaphore.swift; sourceTree = ""; }; + FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewFactory.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 17B157F9FE3789FA97A7085E /* MindboxLoggerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MindboxLoggerTests; sourceTree = ""; }; 471D4C2D2FEECF8800856EA5 /* MindboxNotificationsTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MindboxNotificationsTests; sourceTree = ""; }; 471D4C3B2FEED16200856EA5 /* TestPlans */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = TestPlans; sourceTree = ""; }; + 57A1B3230000000000000203 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; + 57A1B3230000000000000300 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; + A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; - A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1566,6 +1571,17 @@ path = FeatureToggleManager; sourceTree = ""; }; + 0E8BCE24EE9540D6BD7F655D /* Prewarm */ = { + isa = PBXGroup; + children = ( + 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */, + DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */, + 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */, + 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */, + ); + path = Prewarm; + sourceTree = ""; + }; 2B4C84F6EDD4B7D977F67A95 /* WebView */ = { isa = PBXGroup; children = ( @@ -1646,6 +1662,7 @@ 31A20D4225B6B6D700AAA0A3 /* PersistenceStorage */, A179589C2979708B00609E91 /* Resources */, 33EBF0AE264E6269002A35D5 /* SessionManager */, + 57A1B3230000000000000300 /* EmbeddedBlocks */, 84F5655C2628433900269FD6 /* TrackVisitManager */, D2F7E2402BADB89900B24BB8 /* UserVisitManager */, 31A20D4A25B6E09900AAA0A3 /* Utilities */, @@ -1676,6 +1693,7 @@ 84B625F525C98EE000AB6228 /* DI */, 84B625EE25C98A8000AB6228 /* Validators */, F3CD20282F600A800065392A /* Configuration */, + 57A1B3230000000000000203 /* EmbeddedBlocks */, 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */, A1B2C3D4E5F60718293A4B02 /* MindboxOperationsTests.swift */, 84DC49D525D185A600D5D758 /* Supporting Files */, @@ -3098,17 +3116,6 @@ path = InAppTargetingChecker; sourceTree = ""; }; - 0E8BCE24EE9540D6BD7F655D /* Prewarm */ = { - isa = PBXGroup; - children = ( - 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */, - DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */, - 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */, - 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */, - ); - path = Prewarm; - sourceTree = ""; - }; B4E4386F2D8AFA5700603F3A /* WebView */ = { isa = PBXGroup; children = ( @@ -3892,6 +3899,7 @@ F3FEEAA02C25B4F5000E9D0F /* Injections */ = { isa = PBXGroup; children = ( + F3A97FFB302C707F005EA1AF /* InjectEmbeddedBlocks.swift */, F3FEEAA12C25B55C000E9D0F /* InjectCore.swift */, F3FEEAA32C25B765000E9D0F /* InjectUtilities.swift */, F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */, @@ -3955,6 +3963,9 @@ dependencies = ( A170EDDF29B0883800CE547F /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 57A1B3230000000000000300 /* EmbeddedBlocks */, + ); name = Mindbox; productName = MindBox; productReference = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; @@ -3974,6 +3985,7 @@ 313B233C25ADEA0F00A1CB72 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 57A1B3230000000000000203 /* EmbeddedBlocks */, A8C878878353491FA01AC096 /* WebViewPrewarmTests */, F385631E2DB6729000D91208 /* InappConfigurationDataFacade */, F397DE1C2CFF568800B72DA9 /* JSONs */, @@ -4373,6 +4385,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 57A1B3230000000000000007 /* (null) in Sources */, A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */, F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */, 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */, @@ -4554,6 +4567,7 @@ A153E03B29BAFE01003C34D4 /* CustomOperationTargeting.swift in Sources */, F361284B2BA31AC4000382D9 /* ActionUseCaseFactory.swift in Sources */, 9BC24E7528F6953D00C2619C /* ConfigResponse.swift in Sources */, + F3A97FFC302C707F005EA1AF /* InjectEmbeddedBlocks.swift in Sources */, 47CFDABB2F3A054A0034F310 /* MBSystemInfoProvider.swift in Sources */, F331DD332A84D57800222120 /* VariantImageUrlExtractorService.swift in Sources */, 33072F482664CCBA001F1AB2 /* PoolResponse.swift in Sources */, diff --git a/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift new file mode 100644 index 000000000..2d2a7bdba --- /dev/null +++ b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift @@ -0,0 +1,32 @@ +// +// InjectEmbeddedBlocks.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +extension MBContainer { + func registerEmbeddedBlocks() -> Self { + // The resolver is shared: its per-id cache and its queue of waiting blocks are what make + // several blocks with the same id resolve through a single load of the data. The action + // router is shared because it is stateless. Providers, in contrast, are made per block by + // the factory, so blocks stay independent of each other. + register(EmbeddedBlockResolving.self) { + EmbeddedBlockResolver() + } + + register(EmbeddedBlockActionHandling.self) { + EmbeddedBlockActionRouter() + } + + register(EmbeddedBlockContentProviderMaking.self) { + EmbeddedBlockContentProviderFactory(resolver: DI.injectOrFail(EmbeddedBlockResolving.self), + actionHandler: DI.injectOrFail(EmbeddedBlockActionHandling.self)) + } + + return self + } +} diff --git a/Mindbox/DI/MBInject.swift b/Mindbox/DI/MBInject.swift index 27c6a7312..c5e0a14ec 100644 --- a/Mindbox/DI/MBInject.swift +++ b/Mindbox/DI/MBInject.swift @@ -51,6 +51,7 @@ enum MBInject { .registerReplaceableUtilities() .registerInappTools() .registerInappPresentation() + .registerEmbeddedBlocks() } public static var buildTestContainer: () -> MBContainer = { diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift new file mode 100644 index 000000000..97aa344c1 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -0,0 +1,35 @@ +// +// EmbeddedBlockActionRouter.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +protocol EmbeddedBlockActionHandling: AnyObject { + func handle(_ action: EmbeddedBlockPageAction) +} + +final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { + + private enum ActionType { + static let openUrl = "openUrl" + } + + func handle(_ action: EmbeddedBlockPageAction) { + switch action.type { + case ActionType.openUrl: + // TODO(MOBILE-328): open the url once blocks move to the shared in-app bridge. + Logger.common(message: "[EmbeddedBlock] openUrl is not handled yet, ignoring: \(action.type)", + category: .embeddedBlocks) + default: + Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", + category: .embeddedBlocks) + } + } + + // TODO: - Will reuse webView route logic from inapps +} diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift new file mode 100644 index 000000000..da423e7e7 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift @@ -0,0 +1,36 @@ +// +// EmbeddedBlockContentProviderFactory.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Builds a content provider for a specific block. +/// +/// A provider belongs to one container, so it is created anew for every block — this is what +/// makes blocks with the same id independent. +protocol EmbeddedBlockContentProviderMaking { + func makeProvider(id: String) -> EmbeddedBlockWebViewProvider +} + +final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking { + + private let resolver: EmbeddedBlockResolving + private let actionHandler: EmbeddedBlockActionHandling + + init(resolver: EmbeddedBlockResolving, + actionHandler: EmbeddedBlockActionHandling) { + self.resolver = resolver + self.actionHandler = actionHandler + } + + func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { + EmbeddedBlockWebViewProvider(id: id, + resolver: resolver, + actionHandler: actionHandler, + makePage: { EmbeddedBlockWebViewPage(content: $0) }) + } +} diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift new file mode 100644 index 000000000..4bcf82fcd --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift @@ -0,0 +1,53 @@ +// +// EmbeddedBlockLayerHost.swift +// Mindbox +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// Holds exactly one view in the container, stretched to its edges. +/// +/// The block's layers — placeholder, content, error screen — are mutually exclusive: showing a new +/// one means removing the previous one. The shown view is remembered separately from the +/// container's properties, because the host can swap it at any moment, and the view to remove is +/// the one that is actually attached, not the one sitting in a property right now. +final class EmbeddedBlockLayerHost { + + /// The owner holds the host, so the back reference must not count — or the container never dies. + private unowned let container: UIView + + private var attachedView: UIView? + + init(container: UIView) { + self.container = container + } + + /// Shows the view in place of the one attached now. `nil` — show nothing. + func show(_ view: UIView?) { + // "Show nothing" is simply removing the current view: there is nothing to compare here, and + // the shared condition below, made to cover nil, would read as something it is not. + guard let view else { + attachedView?.removeFromSuperview() + attachedView = nil + return + } + + // The same view, and it really is attached to us — no reason to rebuild its constraints. + guard attachedView !== view || view.superview !== container else { return } + + attachedView?.removeFromSuperview() + attachedView = view + + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: container.topAnchor), + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + } +} diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift new file mode 100644 index 000000000..1f9807558 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift @@ -0,0 +1,162 @@ +// +// EmbeddedBlockReadyTimeout.swift +// Mindbox +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import QuartzCore +import MindboxLogger + +/// How long a block is given to show up — and the accounting of that time. +/// +/// The budget belongs to the container, not to the content: whatever the block turns out to be, +/// the host layout does not wait for it forever. A block that misses the budget is collapsed by +/// the container. +/// +/// What is counted is the user's waiting time, not calendar time: while nobody is waiting for the +/// block — the app is in the background, the container is out of the window — the count stands +/// still. Otherwise the user would come back to a block that gave up while nobody was looking. +/// +/// Stands still, but does not start over: what was spent is remembered, and the attempt continues +/// its budget from where it was interrupted. A pause that handed the full budget back would never +/// end — a user switching between apps every five seconds would extend the wait indefinitely, and +/// the host layout would wait forever. Exactly what the budget exists to prevent. Only a new +/// attempt — `reset()` — gets the full budget again. +/// +/// Loading is not affected by the pause: it runs its own course; in the background the system +/// throttles it, not the SDK. + +/// Runs the work when the given remainder of the budget expires. +typealias EmbeddedBlockTimeoutScheduling = (TimeInterval, DispatchWorkItem) -> Void + +final class EmbeddedBlockReadyTimeout { + + /// Whether the count is needed right now: the outcome is still unknown and the block is + /// visible. Asked again on every arm, because both may have changed while paused. + var isNeeded: () -> Bool = { false } + + /// Time is up. Called on the main thread. + var onExpire: () -> Void = {} + + var isRunning: Bool { workItem != nil } + + private let blockId: String + private let duration: TimeInterval + + /// The clock is its own seam: spent time cannot be counted without it, and tests cannot wait + /// out the budget for real — they need a way to say that time has passed. The clock is + /// monotonic, not `Date`: an NTP correction or a manual clock change would make the spent + /// delta negative and stretch the wait past the budget. + private let now: () -> TimeInterval + + /// Notifications are their own seam for the same reason as the clock: going to the background + /// cannot be tested on the global center — a test notification would reach the blocks of tests + /// running next to it. + private let notificationCenter: NotificationCenter + + /// The scheduler is the same kind of seam for the same reason: a hard-wired queue would force + /// budget tests to wait it out in real time — sleeping on every check and flaking on a busy + /// machine. + private let schedule: EmbeddedBlockTimeoutScheduling + + private var workItem: DispatchWorkItem? + + /// How much of the budget past waiting stretches have consumed. + private var consumed: TimeInterval = 0 + + /// When the current stretch started. `nil` — the count is not running. + private var resumedAt: TimeInterval? + + private var remaining: TimeInterval { max(0, duration - consumed) } + + init(blockId: String, + duration: TimeInterval, + now: @escaping () -> TimeInterval = { CACurrentMediaTime() }, + notificationCenter: NotificationCenter = .default, + schedule: @escaping EmbeddedBlockTimeoutScheduling = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }) { + self.blockId = blockId + self.duration = duration + self.now = now + self.notificationCenter = notificationCenter + self.schedule = schedule + + notificationCenter.addObserver(self, + selector: #selector(applicationDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + notificationCenter.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + deinit { + notificationCenter.removeObserver(self) + workItem?.cancel() + } + + /// Arms the count for the remainder of the budget, if it is needed and not already running. + /// Call as often as convenient: extra calls do nothing, so entering the window, returning from + /// the background, and reloading all share the same call. + func armIfNeeded() { + guard workItem == nil, isNeeded() else { return } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + + self.workItem = nil + self.resumedAt = nil + // The budget is spent in full: if the block is somehow armed again, there is nothing + // left to wait for. + self.consumed = self.duration + + Logger.common(message: "[EmbeddedBlock] Block '\(self.blockId)' timed out after \(self.duration)s of waiting", + category: .embeddedBlocks) + self.onExpire() + } + + // The work is stored in the property before it is scheduled: the scheduler is free to run + // it right away, and it must find the budget in a consistent state. + resumedAt = now() + workItem = work + schedule(remaining, work) + } + + /// Stops the count, remembering what was spent. The attempt is not cancelled: `armIfNeeded()` + /// continues it from the remainder. + func pause() { + guard let resumedAt else { return } + + consumed += max(0, now() - resumedAt) + self.resumedAt = nil + workItem?.cancel() + workItem = nil + } + + /// Stops the count and restores the full budget: the previous attempt is over — with an + /// outcome, or because the next one started — and its remainder has nothing to do with the + /// new one. + func reset() { + pause() + consumed = 0 + } + + @objc + private func applicationDidEnterBackground() { + guard isRunning else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(blockId)' went to the background while loading, pausing the timeout", + category: .embeddedBlocks) + pause() + } + + @objc + private func applicationWillEnterForeground() { + armIfNeeded() + } +} diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift new file mode 100644 index 000000000..d95337925 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -0,0 +1,122 @@ +// +// EmbeddedBlockShimmerView.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// The default embedded block placeholder — a neutral tile with a sweeping highlight. +/// +/// Fills the container entirely: the SDK knows nothing about the layout of the content to come, so +/// the placeholder does not depict it and simply marks the reserved spot as "loading". A host that +/// needs a skeleton of its own layout sets the container's `placeholderView`. +/// +/// The animation lives exactly as long as the view is visible: it starts on entering a window and +/// stops on leaving it, including the app going to the background (the system removes CA +/// animations, so the highlight is restarted on return to the foreground). +final class EmbeddedBlockShimmerView: UIView { + + private enum Shimmer { + static let animationKey = "embeddedBlockShimmer" + static let animationDuration: CFTimeInterval = 1.4 + } + + private let gradientLayer = CAGradientLayer() + + private var baseColor: UIColor { + if #available(iOS 13.0, *) { + return .systemGray5 + } + return UIColor(white: 0.90, alpha: 1.0) + } + + private var highlightColor: UIColor { + if #available(iOS 13.0, *) { + return .systemGray6 + } + return UIColor(white: 0.96, alpha: 1.0) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + override func didMoveToWindow() { + super.didMoveToWindow() + + if window == nil { + stopShimmering() + } else { + startShimmering() + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyColors() + } + + private func setUp() { + isUserInteractionEnabled = false + + gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) + gradientLayer.locations = [-1.0, -0.5, 0.0] + applyColors() + layer.addSublayer(gradientLayer) + + NotificationCenter.default.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func applyColors() { + gradientLayer.colors = [ + baseColor.cgColor, + highlightColor.cgColor, + baseColor.cgColor + ] + } + + private func startShimmering() { + guard gradientLayer.animation(forKey: Shimmer.animationKey) == nil else { return } + + let animation = CABasicAnimation(keyPath: "locations") + animation.fromValue = [-1.0, -0.5, 0.0] + animation.toValue = [1.0, 1.5, 2.0] + animation.duration = Shimmer.animationDuration + animation.repeatCount = .infinity + gradientLayer.add(animation, forKey: Shimmer.animationKey) + } + + private func stopShimmering() { + gradientLayer.removeAnimation(forKey: Shimmer.animationKey) + } + + /// The system removes infinite CA animations when the app goes to the background — after + /// coming back the highlight has to be started again. + @objc + private func applicationWillEnterForeground() { + guard window != nil else { return } + startShimmering() + } +} diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift new file mode 100644 index 000000000..67ef28dbd --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift @@ -0,0 +1,31 @@ +// +// EmbeddedBlockState.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +/// The container's own view of the block content. +/// +/// Deliberately internal: the host app only learns whether the block ended up shown or not, +/// never the intermediate progress, so the SDK stays free to change the flow later. +/// +/// The states carry no height: the container is always as tall as the host asked at creation, +/// except for `failed` and `empty`, where it collapses to zero. +enum EmbeddedBlockState: Equatable { + + /// The content has not resolved yet. + case loading + + /// The content is renderable. + case ready + + /// The content failed to resolve — a load error, a timeout or broken content. Collapses + /// the container. + case failed + + /// There is genuinely nothing to show — for instance, the block is disabled in the admin + /// panel. Not a failure, but collapses the container the same way. + case empty +} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift new file mode 100644 index 000000000..4ef47e02c --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift @@ -0,0 +1,44 @@ +// +// EmbeddedBlockPresentation.swift +// Mindbox +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import CoreGraphics + +/// What the container shows right now — a snapshot for the SwiftUI wrapper. +/// +/// A UIKit host needs no such type: the container declares its height through `intrinsicContentSize` +/// and holds the right layer inside, both on its own. SwiftUI can do neither. The wrapper assigns +/// the representable's height, and the host's placeholder and error screen are SwiftUI views the +/// wrapper must draw too: a view handed to the container through a separate `UIHostingController` +/// falls out of the SwiftUI tree and loses its environment. So the wrapper needs not only the +/// size but the current layer too. +/// +/// Deliberately internal, like `EmbeddedBlockState`: the host knows only the outcome, not how the +/// container arrived at it. +struct EmbeddedBlockPresentation: Equatable { + + /// The layer visible in the container. + enum Layer { + + /// Loading is underway: the placeholder is shown — the host's or the SDK's default shimmer. + case placeholder + + /// The block content is shown. + case content + + /// The error screen the host explicitly opted into is shown. + case errorView + + /// The block is collapsed: a failure without an error screen, or an empty block. + case nothing + } + + let layer: Layer + + /// The height the container occupies with this layer. + let height: CGFloat +} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift new file mode 100644 index 000000000..887ef6298 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -0,0 +1,293 @@ +// +// MindboxEmbeddedBlock.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +#if canImport(SwiftUI) +import SwiftUI +import MindboxLogger + +/// SwiftUI wrapper over `MindboxEmbeddedBlockView`. +/// +/// Created with the block `id` from the admin panel and the `height` the block should occupy. +/// Place it anywhere in a layout — the caller decides only the position and the width. The block +/// keeps the given height while its content is loading and shown; a block with nothing to show +/// collapses to zero height. +/// +/// Both values are fixed at creation. A different `id` is a different block, built from scratch in +/// place of the old one. A different `height` changes nothing: the block keeps the height it was +/// created with and reports the ignored value to the log. +/// +/// Both outcomes can be customized the same way as in UIKit, through modifiers on the block +/// itself: `placeholder` replaces the stock loading shimmer, and `errorView` opts into showing a +/// failure instead of collapsing. Both stay ordinary SwiftUI views drawn in place, so they see the +/// environment of the tree they were written in — objects, fonts, locale, color scheme. +/// +/// ```swift +/// MindboxEmbeddedBlock(id: "stories", height: 104, onFail: hideSection) +/// .placeholder { StoriesSkeleton() } +/// .errorView { StoriesUnavailable() } +/// ``` +/// +/// Both modifiers return the block itself, so they come before any SwiftUI modifier: after +/// `.frame(…)` or `.padding(…)` the value is no longer a `MindboxEmbeddedBlock`. +/// +/// A collapsed block is zero points tall, but a stack still pays its spacing around it. To hand the +/// space back completely, drop the whole section from the layout in `onFail` — as in the example +/// above. +@available(iOS 13.0, *) +public struct MindboxEmbeddedBlock: View { + + private let id: String + private let height: CGFloat + private let onLoad: (() -> Void)? + private let onFail: (() -> Void)? + + private(set) var placeholderBuilder: (() -> AnyView)? + private(set) var errorBuilder: (() -> AnyView)? + + /// - Parameters: + /// - id: The block id from the admin panel. + /// - height: The height the block occupies while loading and shown. Fixed at creation: + /// a new value given to a live block is ignored. + /// - onLoad: The block content is shown and the container is visible. + /// - onFail: The block cannot be shown — a failure or an empty block. + public init(id: String, + height: CGFloat, + onLoad: (() -> Void)? = nil, + onFail: (() -> Void)? = nil) { + self.id = id + self.height = height + self.onLoad = onLoad + self.onFail = onFail + } + + /// Shows this view instead of the SDK shimmer while the block is loading. + /// + /// Called again, it replaces the previous placeholder. + public func placeholder(@ViewBuilder _ build: @escaping () -> Content) -> Self { + var block = self + block.placeholderBuilder = { AnyView(build()) } + return block + } + + /// Shows this view instead of collapsing when the block cannot be shown. + /// + /// Applies only to failures: an empty block — one with nothing behind its id — always + /// collapses, so a host cannot fill the space of a block that was never meant to be there. + public func errorView(@ViewBuilder _ build: @escaping () -> Content) -> Self { + var block = self + block.errorBuilder = { AnyView(build()) } + return block + } + + public var body: some View { + EmbeddedBlockBody(id: id, + height: height, + onLoad: onLoad, + onFail: onFail, + placeholder: placeholderBuilder, + errorContent: errorBuilder) + .id(id) + } +} + +@available(iOS 13.0, *) +private struct EmbeddedBlockBody: View { + + let id: String + let height: CGFloat + let onLoad: (() -> Void)? + let onFail: (() -> Void)? + let placeholder: (() -> AnyView)? + let errorContent: (() -> AnyView)? + + /// Starts from the same point the container starts from: the space is taken, the placeholder + /// is shown. The block occupies its height right away, not from the container's first report. + @State private var presentation: EmbeddedBlockPresentation + + init(id: String, + height: CGFloat, + onLoad: (() -> Void)?, + onFail: (() -> Void)?, + placeholder: (() -> AnyView)?, + errorContent: (() -> AnyView)?) { + self.id = id + self.height = height + self.onLoad = onLoad + self.onFail = onFail + self.placeholder = placeholder + self.errorContent = errorContent + _presentation = State(initialValue: EmbeddedBlockPresentation(layer: .placeholder, + height: max(0, height))) + } + + var body: some View { + ZStack { + EmbeddedBlockRepresentable(id: id, + height: height, + presentation: $presentation, + onLoad: onLoad, + onFail: onFail, + hasPlaceholder: placeholder != nil, + hasErrorView: errorContent != nil) + hostLayer + } + .frame(height: presentation.height) + } + + @ViewBuilder private var hostLayer: some View { + switch presentation.layer { + case .placeholder: + if let placeholder { + placeholder() + } + case .errorView: + if let errorContent { + errorContent() + } + case .content, .nothing: + EmptyView() + } + } +} + +@available(iOS 13.0, *) +struct EmbeddedBlockRepresentable: UIViewRepresentable { + + let id: String + let height: CGFloat + + @Binding var presentation: EmbeddedBlockPresentation + + let onLoad: (() -> Void)? + let onFail: (() -> Void)? + + let hasPlaceholder: Bool + let hasErrorView: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(presentation: $presentation, + creationHeight: height, + onLoad: onLoad, + onFail: onFail) + } + + func makeUIView(context: Context) -> MindboxEmbeddedBlockView { + let blockView = MindboxEmbeddedBlockView(id: id, height: height) + let coordinator = context.coordinator + blockView.delegate = coordinator + blockView.onPresentationChange = { presentation in + coordinator.update(presentation) + } + syncStandIns(in: blockView) + return blockView + } + + func updateUIView(_ uiView: MindboxEmbeddedBlockView, context: Context) { + let coordinator = context.coordinator + coordinator.presentation = $presentation + coordinator.onLoad = onLoad + coordinator.onFail = onFail + coordinator.warnIfHeightIsIgnored(height, id: id) + syncStandIns(in: uiView) + } + + static func dismantleUIView(_ uiView: MindboxEmbeddedBlockView, coordinator: Coordinator) { + uiView.onPresentationChange = nil + uiView.delegate = nil + coordinator.detach() + } + + func syncStandIns(in blockView: MindboxEmbeddedBlockView) { + if hasPlaceholder { + if blockView.placeholderView == nil { + blockView.placeholderView = Self.makeStandIn() + } + } else { + blockView.placeholderView = nil + } + + if hasErrorView { + if blockView.errorView == nil { + blockView.errorView = Self.makeStandIn() + } + } else { + blockView.errorView = nil + } + } + + private static func makeStandIn() -> UIView { + let standIn = UIView() + standIn.backgroundColor = .clear + standIn.isUserInteractionEnabled = false + return standIn + } + + final class Coordinator: MindboxEmbeddedBlockViewDelegate { + + var presentation: Binding + var onLoad: (() -> Void)? + var onFail: (() -> Void)? + + private let creationHeight: CGFloat + + private var hasWarnedAboutIgnoredHeight = false + + private var isDetached = false + + /// Where `update` defers its write — `DispatchQueue.main` outside tests. + private let schedule: (@escaping () -> Void) -> Void + + init(presentation: Binding, + creationHeight: CGFloat, + onLoad: (() -> Void)?, + onFail: (() -> Void)?, + schedule: @escaping (@escaping () -> Void) -> Void = { work in DispatchQueue.main.async { work() } }) { + self.presentation = presentation + self.creationHeight = creationHeight + self.onLoad = onLoad + self.onFail = onFail + self.schedule = schedule + } + + func update(_ newPresentation: EmbeddedBlockPresentation) { + schedule { [weak self] in + guard let self, !self.isDetached, + self.presentation.wrappedValue != newPresentation else { return } + self.presentation.wrappedValue = newPresentation + } + } + + /// The height is fixed when the block is created: a new value given to a live block is + /// ignored, and the host has no other way to notice — the block simply keeps standing at + /// its old height. + func warnIfHeightIsIgnored(_ newHeight: CGFloat, id: String) { + guard !hasWarnedAboutIgnoredHeight, newHeight != creationHeight else { return } + + hasWarnedAboutIgnoredHeight = true + Logger.common(message: "[EmbeddedBlock] Block '\(id)' was given height \(newHeight) after creation and keeps \(creationHeight): the height is fixed when the block is created.", + level: .error, + category: .embeddedBlocks) + } + + /// The view left the tree: silences the write `update` has already queued — nulling the + /// callbacks in `dismantleUIView` cannot recall it, and `weak self` is no guarantee: when + /// SwiftUI releases the coordinator after dismantling is unspecified. + func detach() { + isDetached = true + } + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + onLoad?() + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + onFail?() + } + } +} +#endif diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift new file mode 100644 index 000000000..3e404a0fc --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift @@ -0,0 +1,86 @@ +// +// MindboxEmbeddedBlockDebug.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Debug control over embedded block content — for the test app and acceptance testing. +/// +/// Overrides the answer to "what stands behind this id", that is, it takes exactly the place of the +/// admin panel config. Everything below — the resolver, the provider, the page, the container's +/// waiting budget — works unchanged, so acceptance testing exercises the production path rather +/// than a separate test mode. +/// +/// Not part of the public API: available only via `@_spi(Internal) import Mindbox`. Deliberately +/// not stripped from release builds — QA checks exactly what ships to clients — which is why every +/// override that gets set is written to the log. +@_spi(Internal) +public enum MindboxEmbeddedBlockDebug { + + /// What to replace the block content with. + public enum Content { + + /// A page url. This is how scenarios are run against the real network — including a + /// knowingly unreachable address, to get a load failure. + case url(URL) + + /// Ready-made markup. This is how scenarios that do not exist on the network are set up: a + /// page reporting "empty", a silent page, a page that answers after the timeout. + case html(String) + + /// Nothing is attached to the id: the block is turned off in the admin panel or the id is + /// unknown. + case empty + } + + /// Overrides the content of the block with this id. Applies to blocks that start loading after + /// the call: a block that is already shown has to be reloaded or its screen reopened. + public static func setContent(_ content: Content, for id: String) { + EmbeddedBlockContentOverrides.shared.set(content.resolution, for: id) + } + + /// Gives the block its usual content back. + public static func removeContent(for id: String) { + EmbeddedBlockContentOverrides.shared.remove(for: id) + } + + /// Drops every override at once. + public static func removeAllContent() { + EmbeddedBlockContentOverrides.shared.removeAll() + } + + /// Show the block as soon as the document has loaded, without waiting for `ready` from the page. + /// + /// Needed for exactly one scenario: seeing how the block looks and behaves inside the host + /// layout while the web contract is not implemented on the page yet. Under the usual rule such + /// a page stays silent, which means it collapses on the container timeout and there is nothing + /// to see in the block. + /// + /// Off by default and set once at app startup. Keeping it on for longer than the UI check is a + /// bad idea: with the flag on, a broken page looks like a working one. The flag does not cancel + /// `ready` from the page — it only adds a second reason to show the block, so a page that does + /// implement the contract behaves the same with it and without it. + public static var treatsLoadedPageAsReady: Bool { + get { EmbeddedBlockReadinessOverrides.shared.treatsLoadedPageAsReady } + set { EmbeddedBlockReadinessOverrides.shared.setTreatsLoadedPageAsReady(newValue) } + } +} + +@_spi(Internal) +extension MindboxEmbeddedBlockDebug.Content { + + var resolution: EmbeddedBlockResolution { + switch self { + case .url(let url): + return .content(EmbeddedBlockWebContent(url: url)) + case .html(let html): + return .content(EmbeddedBlockWebContent(html: html)) + case .empty: + return .empty + } + } +} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift new file mode 100644 index 000000000..20742e58d --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift @@ -0,0 +1,382 @@ +// +// MindboxEmbeddedBlockView.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// A drop-in container for a Mindbox embedded block. +/// +/// Created with the block `id` from the admin panel and the `height` the block should occupy. +/// Put it anywhere in the app and constrain its position and width only — the height is applied +/// by the container itself through `intrinsicContentSize`: the one given at creation while the +/// content is loading and shown, and 0 when there is nothing to show (a failure or an empty +/// block), so the block takes no space and is invisible in the host layout. Both outcomes can be +/// customized: `placeholderView` replaces the stock loading shimmer, and `errorView` opts into +/// showing a failure instead of collapsing. +/// +/// What exactly lives inside is decided by the SDK from the block `id`, not by the host. The +/// block flow belongs to the SDK too: the container starts its content when it enters a window +/// and stops it when it leaves. The host app observes the outcome through `delegate` and nothing +/// else. +/// +/// The container itself is a machine of four content states and one visible layer for each of +/// them. Everything that could be moved out of it wholesale has been: showing the layers went to +/// `EmbeddedBlockLayerHost`, the waiting budget together with its background pause to +/// `EmbeddedBlockReadyTimeout`. +public final class MindboxEmbeddedBlockView: UIView { + + // MARK: - Host API + + /// The block id from the admin panel, given at creation. Decides what content the SDK puts + /// inside. + public let id: String + + /// Receives the block events. Assigning a delegate after the content already resolved still + /// delivers that outcome, so subscribing late cannot lose it. + public weak var delegate: MindboxEmbeddedBlockViewDelegate? { + didSet { + // The same delegate is not a new subscriber. The host routinely reassigns it on every + // reused cell, and answering that with an already heard outcome is not allowed: the + // host rebuilds its layout on the outcome, and the rebuild reassigns the delegate again. + guard delegate !== oldValue else { return } + + deliveredEvent = nil + scheduleDelivery() + } + } + + /// The view shown in place of the content while it is loading. `nil` — the default — means + /// the SDK's own shimmer. The placeholder fills the whole container, so it is laid out to the + /// container's width and the height given at creation. Can be swapped at any moment, including + /// mid-loading. + public var placeholderView: UIView? { + didSet { + guard placeholderView !== oldValue else { return } + refreshPlaceholder() + } + } + + /// The view shown when the block fails. `nil` — the default — keeps the failure invisible: + /// the container collapses to zero height. Setting a view opts into showing the failure + /// instead: the container keeps the height given at creation and fills itself with this view. + /// The SDK ships no stock error screen — what a failed block looks like is the host's design + /// decision. Applies only to failures; an empty block always collapses. + /// + /// A view assigned mid-failure swaps the error screen that is already shown, but never + /// expands a block that has already collapsed — reopening space the host layout has + /// reclaimed would make the layout jump. Such a view is remembered and takes effect on + /// the next load. + public var errorView: UIView? { + didSet { + guard errorView !== oldValue else { return } + refreshErrorView() + } + } + + /// What the container shows, for the SwiftUI wrapper: it sizes the representable itself instead + /// of relying on `intrinsicContentSize`, and it draws the host's placeholder and error screen + /// itself instead of handing them over as `UIView`s. Internal: not part of the public API. + var onPresentationChange: ((EmbeddedBlockPresentation) -> Void)? + + // MARK: - State + + private let contentProvider: EmbeddedBlockWebViewProvider + + /// The height the host reserved for the block at creation. The container holds it while the + /// content is loading and shown, and drops to 0 when there is nothing to show. + private let preferredHeight: CGFloat + + private let timeout: EmbeddedBlockReadyTimeout + + private lazy var layers = EmbeddedBlockLayerHost(container: self) + + private lazy var defaultPlaceholder = EmbeddedBlockShimmerView() + + private var state: EmbeddedBlockState = .loading { + didSet { + updateTimeout(from: oldValue) + apply(state) + } + } + + /// Whether the block has collapsed since the last explicit reload. + /// + /// Space once ceded to the host is not taken back: a retry — and the block gets one on every + /// return to a window — does not expand the container back for the placeholder. Otherwise a + /// block that failed to show would jerk the host layout by its height and flash the shimmer + /// on every pass across the screen, showing nothing in the end. Only shown content expands it + /// back — or an explicit reload, which is precisely consent to the full cycle anew. + private var hasCollapsed = false + + /// The layer shown right now. Stored, not computed on the fly: it is the one source for both + /// the height and the report to the wrapper — otherwise they could diverge. It also tells a + /// shown error screen from one merely assigned: an `errorView` given after collapse is not shown. + private var shownLayer: EmbeddedBlockPresentation.Layer = .placeholder + + /// There are two public outcomes: shown or not shown. To the host "empty" is the same non-show + /// as a failure; the difference lives only in the container (an empty block shows no `errorView`). + private enum BlockEvent { + case loaded + case failed + } + + /// The last event handed to the current delegate — keeps the same outcome from being reported + /// twice, for instance when restarted content fails again. + private var deliveredEvent: BlockEvent? + + private var isDeliveryScheduled = false + + // MARK: - Life cycle + + /// - Parameters: + /// - id: The block id from the admin panel. + /// - height: The height the block occupies while loading and shown. Reserving it is the + /// host's job and there is no default: a height of 0 or less leaves the block invisible + /// whatever its content turns out to be, so the SDK reports it as an integration error. + public convenience init(id: String, height: CGFloat) { + self.init(id: id, + height: height, + contentProvider: DI.injectOrFail(EmbeddedBlockContentProviderMaking.self).makeProvider(id: id)) + } + + /// Blocks are not created from storyboards: the id and the height are required and have no + /// sensible defaults. Create the view in code with `init(id:height:)`. + @available(*, unavailable, message: "Use init(id:height:) instead") + public required init?(coder: NSCoder) { + return nil + } + + /// - Parameter timeout: The waiting budget as a whole, not just its duration: the clock, the + /// scheduler, and the background subscription all live inside it, and swapping them one by + /// one through the container would mean threading three parameters through it just for tests. + init(id: String, + height: CGFloat, + contentProvider: EmbeddedBlockWebViewProvider, + timeout: EmbeddedBlockReadyTimeout? = nil) { + self.id = id + self.preferredHeight = height + self.contentProvider = contentProvider + self.timeout = timeout ?? EmbeddedBlockReadyTimeout( + blockId: id, + duration: TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) + ) + super.init(frame: .zero) + warnIfHeightReservesNothing() + setUpContainer() + } + + /// The host sets the block height, and zero means not "the block collapsed" but "no space was + /// reserved for it": the block runs its whole cycle, hands the host its events and stays + /// invisible. There are no symptoms at all — the block is simply not visible — and the cause is + /// the most common one possible, so the SDK says it out loud. + private func warnIfHeightReservesNothing() { + guard preferredHeight <= 0 else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' was created with height \(preferredHeight): it reserves no space and stays invisible even when its content loads. Pass the height the block should occupy.", + level: .error, + category: .embeddedBlocks) + } + + deinit { + timeout.pause() + contentProvider.stop() + } + + private func setUpContainer() { + clipsToBounds = true + backgroundColor = .clear + + contentProvider.onStateChange = { [weak self] state in + self?.state = state + } + + timeout.isNeeded = { [weak self] in + guard let self else { return false } + return self.window != nil && self.state == .loading + } + timeout.onExpire = { [weak self] in + self?.handleTimeout() + } + + // The block reserves its space right away, before any loading starts, and reserved space + // must not look blank. + layers.show(view(for: shownLayer)) + } + + // MARK: - Layout + + override public var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) + } + + /// Hosts that lay out by frames rather than by constraints get the same height here. + override public func sizeThatFits(_ size: CGSize) -> CGSize { + CGSize(width: size.width, height: contentHeight) + } + + private var contentHeight: CGFloat { + switch shownLayer { + case .placeholder, .content, .errorView: return max(0, preferredHeight) + case .nothing: return 0 + } + } + + // MARK: - Visibility + + /// Visibility drives the content: there is no reason to hold content for a container that is + /// not in a window, and no public way for the host to start or stop it by hand. + override public func didMoveToWindow() { + super.didMoveToWindow() + + if window == nil { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' left the window, stopping content", category: .embeddedBlocks) + // A pause, not a reset: nobody waits for a block outside a window, but that does not + // cancel an attempt already started — once back, it counts down its remainder. + timeout.pause() + contentProvider.stop() + } else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' entered the window, starting content", category: .embeddedBlocks) + contentProvider.start() + timeout.armIfNeeded() + } + } + + /// Reloads the block: the content starts loading from scratch, the address is requested anew + /// bypassing the cache, the block returns to loading with its placeholder and a fresh timeout. + /// + /// Internal and without a public wrapper: automatic reloads — on failure, on returning to the + /// app — will be built on this method, and there is no reason yet for the host to decide when + /// to refresh the block. + func reload() { + guard window != nil else { + // Content lives only while the block is in a window; reloading an invisible block is + // pointless — it loads anew by itself once it returns to a window. + Logger.common(message: "[EmbeddedBlock] Block '\(id)' reload skipped: the block is not in a window", + category: .embeddedBlocks) + return + } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' reload requested", category: .embeddedBlocks) + timeout.reset() + // A new attempt means a new outcome: the host must hear it in full, even if it matches + // the previous one. + deliveredEvent = nil + // And a new attempt is entitled to take space again: a reload is the host's explicit consent + // to the full cycle with the placeholder, unlike the block's silent return to a window. + hasCollapsed = false + contentProvider.reload() + timeout.armIfNeeded() + } + + private func handleTimeout() { + // Stop first: the provider must not resurrect content the container already gave up on. + contentProvider.stop() + state = .failed + } + + // MARK: - Layers + + /// The waiting budget lives per attempt, not per state: an ongoing load counts down its + /// remainder, while everything else — a known outcome or a load started anew — resets the count. + /// Arming it again is up to whoever knows whether the block is awaited: entering a window and + /// returning from the background. + private func updateTimeout(from previous: EmbeddedBlockState) { + guard state != .loading || previous != .loading else { return } + + timeout.reset() + } + + private func apply(_ state: EmbeddedBlockState) { + shownLayer = layer(for: state) + + if shownLayer == .nothing { + hasCollapsed = true + } + + layers.show(view(for: shownLayer)) + + invalidateIntrinsicContentSize() + onPresentationChange?(EmbeddedBlockPresentation(layer: shownLayer, height: contentHeight)) + scheduleDelivery() + } + + private func layer(for state: EmbeddedBlockState) -> EmbeddedBlockPresentation.Layer { + switch state { + // A collapsed block stays collapsed even while it loads anew: a retry does not win back + // the space the host has already reclaimed. + case .loading: return hasCollapsed ? .nothing : .placeholder + case .ready: return .content + // A failure is shown only to those who opted in explicitly; for the rest the block collapses. + case .failed: return errorView == nil ? .nothing : .errorView + case .empty: return .nothing + } + } + + private func view(for layer: EmbeddedBlockPresentation.Layer) -> UIView? { + switch layer { + case .placeholder: return placeholderView ?? defaultPlaceholder + case .content: return contentProvider.contentView + case .errorView: return errorView + case .nothing: return nil + } + } + + /// A placeholder swap takes effect immediately, but only while the placeholder is what the + /// container shows — in any other state the new view is simply remembered for the next load. + private func refreshPlaceholder() { + guard shownLayer == .placeholder else { return } + layers.show(view(for: .placeholder)) + } + + /// Swapping a shown error screen takes effect immediately, but a collapsed block is not + /// reopened retroactively: the host layout already reclaimed the space, and expanding it out + /// of nowhere would make the layout jump. The new view is remembered for the next load. In + /// any other state the view is simply remembered too. + private func refreshErrorView() { + guard state == .failed, shownLayer == .errorView else { return } + apply(state) + } + + // MARK: - Host events + + /// Events are handed over on the next main-queue turn: the state can flip in the middle of a + /// layout pass, and re-entering host code from there is a good way to break the host's layout. + private func scheduleDelivery() { + guard !isDeliveryScheduled else { return } + + isDeliveryScheduled = true + DispatchQueue.main.async { [weak self] in + self?.deliverPendingEvent() + } + } + + private func deliverPendingEvent() { + isDeliveryScheduled = false + + guard let delegate = delegate, + let event = event(for: state), + event != deliveredEvent else { + return + } + + deliveredEvent = event + + switch event { + case .loaded: delegate.mindboxEmbeddedBlockViewDidLoad(self) + case .failed: delegate.mindboxEmbeddedBlockViewDidFail(self) + } + } + + private func event(for state: EmbeddedBlockState) -> BlockEvent? { + switch state { + case .loading: return nil + case .ready: return .loaded + case .failed, .empty: return .failed + } + } +} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift new file mode 100644 index 000000000..1b352c1f9 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift @@ -0,0 +1,38 @@ +// +// MindboxEmbeddedBlockViewDelegate.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// The events a host app can observe on `MindboxEmbeddedBlockView`. +/// +/// The block resolves into one of two outcomes: it is either shown or it is not. Everything the +/// host can react to fits these two calls, so the API has no more of them — intermediate states +/// like "started loading" stay internal. +/// +/// Every method has an empty default implementation, so only the interesting ones have to be +/// written. Calls always arrive on the main thread. Every method hands back the view that fired +/// it — with several blocks on screen, compare it against your own references (or give each block +/// its own delegate) to tell them apart. +public protocol MindboxEmbeddedBlockViewDelegate: AnyObject { + + /// The block content is shown: the container has taken its own height and is visible. + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) + + /// The block cannot be shown. Covers failures — the load broke, timed out or the content is + /// malformed — and also the empty block, one that has nothing behind its id. On a failure the + /// container collapses to zero height, or keeps its height and shows `errorView` when one is + /// set; an empty block always collapses, `errorView` does not apply to it. + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) +} + +public extension MindboxEmbeddedBlockViewDelegate { + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) {} + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) {} +} diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift new file mode 100644 index 000000000..e0a6731cc --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift @@ -0,0 +1,86 @@ +// +// EmbeddedBlockContentOverrides.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// What to substitute for the content attached to a block id. +protocol EmbeddedBlockContentOverriding: AnyObject { + + func resolution(for id: String) -> EmbeddedBlockResolution? +} + +/// A debug override of the block content — what acceptance testing uses to reproduce scenarios that +/// are not published on the network: an empty block, a silent page, an answer that comes after the +/// timeout, an unknown message. +/// +/// The override sits in the place of the config, so the whole path below it — the resolver, the +/// provider, the page, the container timeout — works for real; only the source of the block data +/// changes. The resolver cache is not used for an overridden id, so that switching a scenario +/// applies right away. +/// +/// Hidden behind `@_spi(Internal)`: it is absent from the regular API, but it is not stripped from +/// release builds either — QA checks what ships to clients. Every override that gets set is written +/// to the log, so an enabled override is impossible to miss. +final class EmbeddedBlockContentOverrides: EmbeddedBlockContentOverriding { + + static let shared = EmbeddedBlockContentOverrides() + + /// The override is set from the app's QA code, while the resolver reads it on the main thread — + /// the threads may differ. + private let lock = NSLock() + + private var overrides: [String: EmbeddedBlockResolution] = [:] + + func set(_ resolution: EmbeddedBlockResolution, for id: String) { + lock.lock() + overrides[id] = resolution + lock.unlock() + + Logger.common(message: "[EmbeddedBlock] Debug override is ON for block id '\(id)': \(describe(resolution))", + level: .default, + category: .embeddedBlocks) + } + + func remove(for id: String) { + lock.lock() + let removed = overrides.removeValue(forKey: id) != nil + lock.unlock() + + guard removed else { return } + Logger.common(message: "[EmbeddedBlock] Debug override is OFF for block id '\(id)'", category: .embeddedBlocks) + } + + func removeAll() { + lock.lock() + let hadAny = !overrides.isEmpty + overrides = [:] + lock.unlock() + + guard hadAny else { return } + Logger.common(message: "[EmbeddedBlock] All debug overrides are OFF", category: .embeddedBlocks) + } + + func resolution(for id: String) -> EmbeddedBlockResolution? { + lock.lock() + defer { lock.unlock() } + return overrides[id] + } + + private func describe(_ resolution: EmbeddedBlockResolution) -> String { + switch resolution { + case .empty: + return "empty" + case .content(let content): + switch content.source { + case .url(let url): return url.absoluteString + case .html: return "inline html" + } + } + } +} diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift new file mode 100644 index 000000000..58ae4db55 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -0,0 +1,150 @@ +// +// EmbeddedBlockResolver.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// What an embedded block id resolves into. +enum EmbeddedBlockResolution: Equatable { + + /// There is content attached to the id — the block loads it. + case content(EmbeddedBlockWebContent) + + /// There is nothing behind the id — the block is turned off in the admin panel or the id is + /// unknown. Not an error. + case empty +} + +/// Answers a single question: what does the block with this id show. +/// +/// The resolver is the shared point for every container: several blocks with the same id resolve +/// with the same data, while the view, the page and the state stay per block. Works on the main +/// thread; the completion may arrive either synchronously (cache) or later (remote config). +protocol EmbeddedBlockResolving: AnyObject { + + /// - Parameter forceRefresh: `true` — skip the cache, ask for the data again. Needed by a block + /// reload: a block that moved or was turned off would otherwise keep pulling the old address + /// from the cache forever. Laid down in advance and deliberately not exposed: while "once per + /// SDK initialization" holds, a reload cannot be triggered from the app. + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) +} + +extension EmbeddedBlockResolving { + + func resolve(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + resolve(id, forceRefresh: false, completion: completion) + } +} + +/// Where the resolver learns what stands behind a block id. +/// +/// For now it is a stub with a static page. Once the admin panel config arrives, the real loading +/// will live here, while the cache and the queue of waiters in the resolver stay unchanged. +/// +/// It may answer from any thread: the resolver moves the answer to the main thread itself, because +/// that is where it updates the cache and the queue of waiters, and where the block views wait for +/// the answer. +typealias EmbeddedBlockContentLoading = (String, @escaping (EmbeddedBlockResolution) -> Void) -> Void + +final class EmbeddedBlockResolver: EmbeddedBlockResolving { + + /// The stories feed page on static hosting. Hardcoded for now: once the admin panel config + /// arrives, the address will come from there together with the id → content mapping. + private static let storiesPageURL = "https://mobile-static.mindbox.ru/beta/inapps/webview/content/stories.html" + + private let load: EmbeddedBlockContentLoading + private let overrides: EmbeddedBlockContentOverriding + + /// A cache per id: an answer received once is handed to every following block immediately. + /// + /// Lives until the end of the process and is never invalidated — including `.empty`. That is a + /// decision, not an oversight: a block resolves once per SDK initialization, full stop. It + /// follows that a block turned off in the admin panel, or one that did not make it at app + /// startup, will not appear until a restart — and that is by design. This may change later, but + /// for now it is so. + private var cache: [String: EmbeddedBlockResolution] = [:] + + /// Who is already waiting for the answer for this id. "One data load per id" means that a + /// second block with the same id joins this queue instead of going for the data itself. + private var waiting: [String: [(EmbeddedBlockResolution) -> Void]] = [:] + + init(load: @escaping EmbeddedBlockContentLoading = EmbeddedBlockResolver.loadStubbedStoriesPage, + overrides: EmbeddedBlockContentOverriding = EmbeddedBlockContentOverrides.shared) { + self.load = load + self.overrides = overrides + } + + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + // The cache and the queue of waiters are plain dictionaries: every path through them has to + // run on one thread — the same one the block views wait on. + guard Thread.isMainThread else { + Logger.common(message: "[EmbeddedBlock] Resolver was asked about id '\(id)' off the main thread, continuing on it", + level: .error, + category: .embeddedBlocks) + DispatchQueue.main.async { [weak self] in + self?.resolve(id, forceRefresh: forceRefresh, completion: completion) + } + return + } + + // The debug override outranks both the data and the cache: acceptance testing switches + // scenarios on the fly, and a cached answer would get in the way. + if let overridden = overrides.resolution(for: id) { + completion(overridden) + return + } + + if !forceRefresh, let cached = cache[id] { + completion(cached) + return + } + + // A load for this id is already in flight. Joining it is right for `forceRefresh` too: the + // answer it is about to bring is fresh by definition. + if waiting[id] != nil { + waiting[id]?.append(completion) + return + } + + waiting[id] = [completion] + + load(id) { [weak self] resolution in + guard Thread.isMainThread else { + DispatchQueue.main.async { + self?.finish(id, with: resolution) + } + return + } + + self?.finish(id, with: resolution) + } + } + + /// The answer has arrived: it goes into the cache and is handed to the whole queue that waited + /// for it. + private func finish(_ id: String, with resolution: EmbeddedBlockResolution) { + cache[id] = resolution + let completions = waiting.removeValue(forKey: id) ?? [] + completions.forEach { $0(resolution) } + } + + /// There is no config yet, so any id resolves into the stories feed page. This is the single + /// place the real admin panel config will replace: id → block content, a turned off or unknown + /// block → `.empty`. + static func loadStubbedStoriesPage(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + guard let url = URL(string: storiesPageURL) else { + Logger.common(message: "[EmbeddedBlock] Invalid stories page URL, resolving id '\(id)' as empty", + category: .embeddedBlocks) + completion(.empty) + return + } + + Logger.common(message: "[EmbeddedBlock] Resolved block id '\(id)' to \(url.absoluteString)", category: .embeddedBlocks) + completion(.content(EmbeddedBlockWebContent(url: url))) + } +} diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift new file mode 100644 index 000000000..efc92d9e2 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -0,0 +1,32 @@ +// +// EmbeddedBlockWebContent.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +struct EmbeddedBlockWebContent: Equatable { + + enum Source: Equatable { + case url(URL) + + /// Markup instead of an address. Needed by the debug content override: acceptance + /// scenarios — an empty page, a silent page, an answer that comes after the timeout — are + /// not published to the network. + /// TODO: - Remove this once we parse the url from the config + case html(String) + } + + let source: Source + + init(url: URL) { + source = .url(url) + } + + init(html: String) { + source = .html(html) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift new file mode 100644 index 000000000..ff573be8a --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift @@ -0,0 +1,39 @@ +// +// EmbeddedBlockPageHosting.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// The embedded block page — everything the provider needs from the web view. +/// +/// The single seam inside the block and the single place where WebKit lives: this is what lets the +/// translation of page messages into block states be tested without a real web view and without +/// the network. +protocol EmbeddedBlockPageHosting: AnyObject { + + /// The page view. The provider hands it to the container as the block content. + var view: UIView { get } + + /// Messages from the page. Delivered on the main thread. + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? { get set } + + /// The page failed to load — connection, domain, a cancelled navigation. That is all navigation + /// reports: whether the page is ready is decided by the page itself with its `ready`. + /// Delivered on the main thread. + var onLoadFailure: (() -> Void)? { get set } + + /// The document has loaded. That does not mean the block is ready — the page may be empty or + /// broken — so the regular path ignores this signal: the only listener is the debug readiness + /// override for pages without the web contract. Delivered on the main thread. + var onLoadFinish: (() -> Void)? { get set } + + func load() + + /// Stops the loading. The page and its bridge stay in place: the block may come back into the + /// window, and then the already rendered page is shown again without a reload. + func cancel() +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift new file mode 100644 index 000000000..edc13a94d --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift @@ -0,0 +1,94 @@ +// +// EmbeddedBlockPageMessage.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import CoreGraphics +import Foundation + +/// What the embedded block page reports to the native side. +/// +/// The core parses only the core layer — `ready`, `heightChanged` and `empty`, which every block +/// needs. Everything else with a valid envelope goes to the mechanic as an `action`: the core does +/// not know, and must not know, the vocabulary of a particular mechanic. +/// +/// The format is our own and minimal for now: the page sends `{"type": ..., ...}`. Converging with +/// the shared in-app JS bridge (`MindboxWebBridge`) is a separate task; until then this parsing does +/// not need to be touched. +enum EmbeddedBlockPageMessage: Equatable { + + /// The page has rendered and asks the container to become `height` points tall. + case ready(height: CGFloat) + + /// The page re-measured itself after it was shown — for example, more content was loaded. + case heightChanged(height: CGFloat) + + /// The page has nothing to show — for example, the block is turned off in the admin panel. This + /// is not an error. + case empty + + /// An action beyond the core layer — its meaning is known to the block mechanic. + case action(EmbeddedBlockPageAction) + + /// The message body arrives from WebKit as `Any`. A string is parsed as JSON, a dictionary is + /// taken as is: the page may send either one, and there is no reason to fail on the message + /// shape here. + init?(body: Any) { + let payload: [String: Any] + + if let dictionary = body as? [String: Any] { + payload = dictionary + } else if let json = body as? String, + let data = json.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + payload = decoded + } else { + return nil + } + + guard let type = payload["type"] as? String else { + return nil + } + + switch type { + case "ready": + guard let height = EmbeddedBlockPageMessage.height(from: payload) else { return nil } + self = .ready(height: height) + case "heightChanged": + guard let height = EmbeddedBlockPageMessage.height(from: payload) else { return nil } + self = .heightChanged(height: height) + case "empty": + self = .empty + default: + self = .action(EmbeddedBlockPageAction(type: type, payload: payload)) + } + } + + /// JS gives a number as a `Double`, but whole values may also arrive as an `Int` — take both. + private static func height(from payload: [String: Any]) -> CGFloat? { + if let height = payload["height"] as? Double { + return CGFloat(height) + } + + if let height = payload["height"] as? Int { + return CGFloat(height) + } + + return nil + } +} + +/// The envelope of an action the core does not parse but passes to the mechanic: the type and the +/// whole message payload as is. +struct EmbeddedBlockPageAction: Equatable { + + let type: String + let payload: [String: Any] + + static func == (lhs: EmbeddedBlockPageAction, rhs: EmbeddedBlockPageAction) -> Bool { + lhs.type == rhs.type && (lhs.payload as NSDictionary).isEqual(to: rhs.payload) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift new file mode 100644 index 000000000..585037ad4 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift @@ -0,0 +1,61 @@ +// +// EmbeddedBlockReadinessOverrides.swift +// Mindbox +// +// Created by vailence on 07.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// A debug override of the block readiness condition. +protocol EmbeddedBlockReadinessOverriding: AnyObject { + + /// `true` — the block becomes ready on the fact of a loaded document, without waiting for + /// `ready` from the page. + var treatsLoadedPageAsReady: Bool { get } +} + +/// A temporary crutch for pages that do not implement the web contract yet. +/// +/// The block's usual rule is that only the page itself declares readiness: a loaded document says +/// nothing about whether the block has anything to show, so a silent page is finished off by the +/// container timeout. While the contract is not implemented on the web side, checking the block +/// layout under this rule is impossible: any page collapses to zero on the timeout. +/// +/// The override lifts exactly this restriction and nothing more: the document has loaded — we show +/// it. It is off by default and turned on only explicitly from the app code, because with the +/// override on a broken page looks like a working one — which is exactly what the usual rule +/// protects against. +/// +/// Goes away together with the first page that learns to send `ready`. +final class EmbeddedBlockReadinessOverrides: EmbeddedBlockReadinessOverriding { + + static let shared = EmbeddedBlockReadinessOverrides() + + /// The flag is set from the app code, while the provider reads it on the main thread — the + /// threads may differ. + private let lock = NSLock() + + private var isLoadedPageTreatedAsReady = false + + var treatsLoadedPageAsReady: Bool { + lock.lock() + defer { lock.unlock() } + return isLoadedPageTreatedAsReady + } + + func setTreatsLoadedPageAsReady(_ isEnabled: Bool) { + lock.lock() + let didChange = isLoadedPageTreatedAsReady != isEnabled + isLoadedPageTreatedAsReady = isEnabled + lock.unlock() + + guard didChange else { return } + + Logger.common(message: "[EmbeddedBlock] Debug readiness is \(isEnabled ? "ON" : "OFF"): a loaded page \(isEnabled ? "is" : "is no longer") treated as ready without the page contract", + level: .default, + category: .embeddedBlocks) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift new file mode 100644 index 000000000..d079d8485 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -0,0 +1,155 @@ +// +// EmbeddedBlockWebViewPage.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import WebKit +import MindboxLogger + +/// The embedded block page in a WKWebView. +/// +/// The web view comes from `InAppWebViewFactory` — the same place where in-app web views are +/// configured: the block gets the same user agent and the same `WKWebsiteDataStore`, and therefore +/// a shared HTTP cache. +final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { + + /// The handler name is our own until blocks move to the shared in-app bridge. + private enum Constants { + static let handlerName = "mindboxEmbeddedBlock" + } + + let webView: WKWebView + + var view: UIView { webView } + + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + + var onLoadFailure: (() -> Void)? + + var onLoadFinish: (() -> Void)? + + private let content: EmbeddedBlockWebContent + + init(content: EmbeddedBlockWebContent, webView: WKWebView = InAppWebViewFactory.make()) { + self.content = content + self.webView = webView + super.init() + + setUpWebView() + attachBridge() + } + + func load() { + switch content.source { + case .url(let url): + webView.load(URLRequest(url: url)) + case .html(let html): + // A page supplied as markup has an about:blank origin, so it will have neither localStorage nor network requests to its own domain. This case will change or be removed entirely in (MOBILE-328) + webView.loadHTMLString(html, baseURL: nil) + } + } + + func cancel() { + webView.stopLoading() + } + + private func setUpWebView() { + webView.navigationDelegate = self + + // The background is transparent: the app background, not a white sheet, should show through + // the gaps in the content. + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + + // The container height equals the content height, so there is nothing to scroll vertically — + // otherwise the block would bounce under the finger on every horizontal swipe. + webView.scrollView.bounces = false + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + } + + private func attachBridge() { + let controller = webView.configuration.userContentController + // Idempotent: the web view may come from reuse and carry a handler with this name from its + // previous owner. + controller.removeScriptMessageHandler(forName: Constants.handlerName) + // WKUserContentController holds the handler strongly, so a weak proxy goes into it — + // otherwise the page and the web view would never be released. + controller.add(EmbeddedBlockWebViewMessageProxy(receiver: self), name: Constants.handlerName) + } + + fileprivate func receive(body: Any) { + guard let message = EmbeddedBlockPageMessage(body: body) else { + Logger.common(message: "[EmbeddedBlock] Unknown page message: \(body)", category: .embeddedBlocks) + return + } + + onMessage?(message) + } +} + +/// Navigation only judges its own business: the load failed or the document arrived. Block +/// readiness does not follow from that — it is declared by the page itself with its `ready`, and +/// the only listener of a loaded document is the debug readiness override. +extension EmbeddedBlockWebViewPage: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + onLoadFinish?() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + reportLoadFailure(error, phase: "navigation") + } + + func webView(_ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + reportLoadFailure(error, phase: "provisional navigation") + } +} + +private extension EmbeddedBlockWebViewPage { + + /// A cancelled navigation is not a load failure, and passing it off as one is not allowed: the + /// block would collapse out of nowhere and stay a zero-height hole until the end of the screen's + /// life. WebKit returns `NSURLErrorCancelled` in two perfectly ordinary cases: the navigation + /// was superseded by the next one — a client-side redirect, the page will load on its own — and + /// the navigation was stopped by us, by calling `cancel()` on a block that went off screen. The + /// second case also arrives after the block is back in the window, so the provider will not + /// filter it out with its `isStarted`. + func reportLoadFailure(_ error: Error, phase: String) { + let error = error as NSError + + guard !(error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled) else { + Logger.common(message: "[EmbeddedBlock] Page \(phase) was cancelled, not a load failure", + category: .embeddedBlocks) + return + } + + Logger.common(message: "[EmbeddedBlock] Page \(phase) failed: \(error.localizedDescription)", + category: .embeddedBlocks) + onLoadFailure?() + } +} + +/// A weak layer between `WKUserContentController` and the page. +private final class EmbeddedBlockWebViewMessageProxy: NSObject, WKScriptMessageHandler { + + private weak var receiver: EmbeddedBlockWebViewPage? + + init(receiver: EmbeddedBlockWebViewPage) { + self.receiver = receiver + super.init() + } + + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + receiver?.receive(body: message.body) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift new file mode 100644 index 000000000..bc7a30502 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -0,0 +1,277 @@ +// +// EmbeddedBlockWebViewProvider.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Embedded block content — a web page found by the block id. +/// +/// The provider does not draw content and knows no mechanics: it asks the resolver what stands +/// behind the id, translates the page's core messages into container states, and hands actions +/// beyond the core layer to the universal handler. +/// +/// An instance belongs to one container, so `start()` and `stop()` simply mirror its visibility +/// and can be called in cycles. After `stop()` the provider must stay silent until the next +/// `start()` — the container relies on this when it collapses an expired block. +final class EmbeddedBlockWebViewProvider { + + /// Reports every state change on the main thread. Set by the container. + var onStateChange: ((EmbeddedBlockState) -> Void)? + + var contentView: UIView? { isReady ? page?.view : nil } + + private let id: String + private let resolver: EmbeddedBlockResolving + private let actionHandler: EmbeddedBlockActionHandling + private let readinessOverrides: EmbeddedBlockReadinessOverriding + private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting + + /// The page survives restarts: the container starts and stops the block by visibility, and + /// there is no reason to recreate the web view on every return to the window. + private var page: EmbeddedBlockPageHosting? + + private var isStarted = false + + /// How the current attempt ended: `nil` — nothing yet. + /// + /// The outcome survives `stop()`: it is a property of the page, not of being in the window. + /// A failure and `empty` do not kill the page — it is alive and can keep talking — so a known + /// outcome is also needed as a sign that the block is no longer on screen. + private var outcome: EmbeddedBlockState? + + private var isReady: Bool { outcome == .ready } + + /// The number of the current load attempt. A resolve may answer after `stop()` or after a + /// reload — the number shows that the answer belongs to a past attempt and must be thrown away. + private var loadGeneration = 0 + + init(id: String, + resolver: EmbeddedBlockResolving, + actionHandler: EmbeddedBlockActionHandling, + readinessOverrides: EmbeddedBlockReadinessOverriding = EmbeddedBlockReadinessOverrides.shared, + makePage: @escaping (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting) { + self.id = id + self.resolver = resolver + self.actionHandler = actionHandler + self.readinessOverrides = readinessOverrides + self.makePage = makePage + + EmbeddedBlockWebViewProvider.blockCreated(id: id) + } + + deinit { + EmbeddedBlockWebViewProvider.blockReleased(id: id) + } + + func start() { + start(forceRefresh: false) + } + + func stop() { + guard isStarted else { return } + + isStarted = false + // The outcome is not reset: it is a property of the page, not of being in the window. + // Otherwise every pass of the block across the screen would cost a full reload. + loadGeneration += 1 + page?.cancel() + } + + /// Starts the load from scratch: drops the page and requests the address again, bypassing the + /// resolver cache — otherwise a moved or disabled block would forever get its previous address. + func reload() { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is reloading", category: .embeddedBlocks) + + // The previous page is no longer relevant — detach it from us first so that its late + // messages do not end up in the new attempt. + page?.onMessage = nil + page?.onLoadFailure = nil + page?.onLoadFinish = nil + page?.cancel() + page = nil + + isStarted = false + outcome = nil + loadGeneration += 1 + + start(forceRefresh: true) + } + + func handle(_ message: EmbeddedBlockPageMessage) { + guard isStarted else { return } + + switch message { + case .ready(let height): + apply(height: height) + case .heightChanged(let height): + // The host owns the height — the message stays in the page contract but affects + // nothing on the native side. + Logger.common(message: "[EmbeddedBlock] Ignored heightChanged(\(height)): the host owns the container height", category: .embeddedBlocks) + case .empty: + outcome = .empty + onStateChange?(.empty) + case .action(let action): + // The block is not on screen, but the page is alive and keeps working — for example, + // delivering what its `setTimeout` scheduled. Its actions must not run at this moment: + // not a single user touch stands behind an invisible block, and `openUrl` would take + // the user out of the app out of nowhere. + guard isShown else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored action '\(action.type)' from a block that is not shown", + category: .embeddedBlocks) + return + } + + actionHandler.handle(action) + } + } + + func handleLoadFailure() { + guard isStarted else { return } + + outcome = .failed + onStateChange?(.failed) + } + + /// While there is no outcome, the page is still loading — its messages belong to a live block. + private var isShown: Bool { + outcome == nil || outcome == .ready + } + + /// A loaded document means nothing by itself: showing the block on it is allowed only by the + /// debug override — for pages that cannot send `ready` yet. + func handleLoadFinish() { + guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': debug readiness is ON, showing the loaded page without a 'ready' from it", + level: .default, + category: .embeddedBlocks) + outcome = .ready + onStateChange?(.ready) + } + + private func start(forceRefresh: Bool) { + guard !isStarted else { return } + + isStarted = true + + // The page has already rendered and is still around — show it as is. Returning the block + // to the window costs no network, no shimmer, no repeated events to the host. + if isReady, page != nil { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': showing the page rendered earlier", + category: .embeddedBlocks) + onStateChange?(.ready) + return + } + + onStateChange?(.loading) + // A new attempt has started: how the previous one ended no longer matters — including for + // deciding whether to run the page's actions. + outcome = nil + + if let page { + page.load() + return + } + + let generation = loadGeneration + resolver.resolve(id, forceRefresh: forceRefresh) { [weak self] resolution in + guard let self, self.isStarted, self.loadGeneration == generation else { return } + + switch resolution { + case .empty: + Logger.common(message: "[EmbeddedBlock] Block id '\(self.id)' resolved as empty", + category: .embeddedBlocks) + self.onStateChange?(.empty) + case .content(let content): + let page = self.makePage(content) + page.onMessage = { [weak self] message in + self?.handle(message) + } + page.onLoadFailure = { [weak self] in + self?.handleLoadFailure() + } + page.onLoadFinish = { [weak self] in + self?.handleLoadFinish() + } + self.page = page + page.load() + } + } + } + + private func apply(height: CGFloat) { + // The page reports "nothing to show" with an explicit `empty`, so zero height means + // broken layout, that is, a failure. + guard height > 0 else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks) + outcome = .empty + onStateChange?(.failed) + return + } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page is ready", category: .embeddedBlocks) + outcome = .ready + onStateChange?(.ready) + } +} + +// MARK: - Live blocks + +/// How many blocks with each id are alive right now. +/// +/// Diagnostics, not mechanics: two blocks with one id are a legitimate case, both will show the +/// same content. But more often it is either a copied id or a reused cell that got a block +/// container from another row — and neither case has noticeable symptoms beyond "the block ended +/// up not where it was expected". So the SDK reports it to the log. +/// +/// The counter is process-wide because the question is too: identical ids are looked for not +/// inside one block but across blocks. It does not retain live blocks — it stores only counts. +extension EmbeddedBlockWebViewProvider { + + private static var liveBlocks: [String: Int] = [:] + + /// Blocks are created and die with UIKit views, that is, on the main thread. The lock is here + /// in case that ever stops being true: diagnostics must not crash the SDK. + private static let liveBlocksLock = NSLock() + + static func liveCount(for id: String) -> Int { + liveBlocksLock.lock() + defer { liveBlocksLock.unlock() } + + return liveBlocks[id] ?? 0 + } + + fileprivate static func blockCreated(id: String) { + liveBlocksLock.lock() + let count = (liveBlocks[id] ?? 0) + 1 + liveBlocks[id] = count + liveBlocksLock.unlock() + + guard count > 1 else { return } + + Logger.common(message: """ + [EmbeddedBlock] \(count) live blocks share id '\(id)'. They show the same content, \ + each rendered on its own. If that is unexpected, check that a reusable cell is not carrying \ + a block container from another row: a block is created for one id and cannot be repointed. + """, category: .embeddedBlocks) + } + + fileprivate static func blockReleased(id: String) { + liveBlocksLock.lock() + let remaining = max(0, (liveBlocks[id] ?? 1) - 1) + if remaining > 0 { + liveBlocks[id] = remaining + } else { + liveBlocks.removeValue(forKey: id) + } + liveBlocksLock.unlock() + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is released, \(remaining) live with this id", + category: .embeddedBlocks) + } +} diff --git a/Mindbox/Utilities/Constants.swift b/Mindbox/Utilities/Constants.swift index a33b88335..534f369ed 100644 --- a/Mindbox/Utilities/Constants.swift +++ b/Mindbox/Utilities/Constants.swift @@ -115,6 +115,15 @@ enum Constants { static let timeoutSeconds = 7 } + enum EmbeddedBlock { + /// Сколько встроенный блок ждёт, пока страница объявит себя готовой, прежде чем свернуться. + /// + /// Бюджет свой, а не общий с инаппами, даже при совпадающем значении: блок стоит в вёрстке + /// хоста, и его терпение — самостоятельное продуктовое решение, а не следствие таймаута + /// инаппов. + static let readyTimeoutSeconds = 7 + } + enum MagicNumbers { static let daysToKeepInappShowTimes = 2 } diff --git a/MindboxLogger/Shared/Group/LogCategory.swift b/MindboxLogger/Shared/Group/LogCategory.swift index 2f40d1b48..17f271e88 100644 --- a/MindboxLogger/Shared/Group/LogCategory.swift +++ b/MindboxLogger/Shared/Group/LogCategory.swift @@ -23,6 +23,7 @@ public enum LogCategory: String, CaseIterable { case inAppMessages case webViewInAppMessages case appDelegate + case embeddedBlocks var emoji: String { switch self { @@ -52,6 +53,8 @@ public enum LogCategory: String, CaseIterable { return "🕸️" case .appDelegate: return "🪄" + case .embeddedBlocks: + return "📚" } } } diff --git a/MindboxLoggerTests/LogPrimitivesTests.swift b/MindboxLoggerTests/LogPrimitivesTests.swift index 5b71e62b3..550f3659e 100644 --- a/MindboxLoggerTests/LogPrimitivesTests.swift +++ b/MindboxLoggerTests/LogPrimitivesTests.swift @@ -14,7 +14,7 @@ struct LogPrimitivesTests { @Test("Every LogCategory exposes a non-empty emoji") func categoryEmoji() { - #expect(LogCategory.allCases.count == 13) + #expect(LogCategory.allCases.count == 14) for category in LogCategory.allCases { #expect(!category.emoji.isEmpty, "\(category) has no emoji") } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift new file mode 100644 index 000000000..8b2dd49a9 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift @@ -0,0 +1,77 @@ +// +// EmbeddedBlockContentProviderFactoryTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +/// The factory keeps one promise: the provider is its own for every block, while the resolver and +/// the action handler are shared. The independence of blocks sharing an id rests on this, so it is +/// checked on its own. +/// +/// The live-block counter is shared across the process, so each test uses its own id: otherwise +/// tests running in parallel would count each other's blocks. +@Suite("Embedded block content provider factory", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockContentProviderFactoryTests { + + /// Two blocks sharing an id are a legitimate case, and each must get its own provider: a + /// shared one would make their state and page one for both. + @Test("Every call makes its own provider") + func eachCallMakesItsOwnProvider() { + let id = "factory-independent-blocks" + let factory = makeFactory() + + let first = factory.makeProvider(id: id) + let second = factory.makeProvider(id: id) + + #expect(first !== second) + withExtendedLifetime((first, second)) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) + } + } + + @Test("The provider is made for the requested id") + func providerIsMadeForTheRequestedId() { + let id = "factory-carries-the-id" + let other = "factory-some-other-id" + let factory = makeFactory() + + let provider = factory.makeProvider(id: id) + + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) + #expect(EmbeddedBlockWebViewProvider.liveCount(for: other) == 0) + } + } + + /// The resolver is shared exactly so that several blocks with the same id are resolved by one + /// data fetch. This checks that the factory really hands the provider that resolver instead + /// of making its own. + @Test("The provider asks the shared resolver for its own id") + func providerAsksTheSharedResolver() { + let resolver = EmbeddedBlockResolverMock(resolution: .empty) + let factory = EmbeddedBlockContentProviderFactory(resolver: resolver, + actionHandler: EmbeddedBlockActionHandlerMock()) + + let provider = factory.makeProvider(id: "factory-shared-resolver") + withExtendedLifetime(provider) { + provider.start() + } + + #expect(resolver.resolvedIds == ["factory-shared-resolver"]) + } + + // MARK: - Helpers + + /// The resolver answers "empty": no page is created for such a block, so the factory's tests + /// need no real web view. + private func makeFactory() -> EmbeddedBlockContentProviderFactory { + EmbeddedBlockContentProviderFactory(resolver: EmbeddedBlockResolverMock(resolution: .empty), + actionHandler: EmbeddedBlockActionHandlerMock()) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift new file mode 100644 index 000000000..5f96a9a06 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift @@ -0,0 +1,77 @@ +// +// EmbeddedBlockCoordinatorTests.swift +// MindboxTests +// +// Created by vailence on 12.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import SwiftUI +@testable import Mindbox + +/// The coordinator writes the presentation reported by the container deferred — on the next turn +/// of the main queue. `dismantleUIView` silences the container's callbacks, but a write already +/// queued cannot be recalled — `detach()` cancels it: the subscription is re-checked at execution +/// time. +/// +/// The suite is not marked `@available(iOS 13.0, *)` — the `@Suite`/`@Test` macros reject such +/// declarations. The test target builds for iOS 12, so each test opens SwiftUI availability for +/// itself with `guard #available`. +@Suite("Embedded block coordinator", .tags(.embeddedBlocks)) +struct EmbeddedBlockCoordinatorTests { + + /// The write is deferred: the container may report a layer change in the middle of a body + /// pass, and state must not change at that moment. It lands on the scheduler's turn — and is + /// exactly what was reported. + @Test("Update writes on the scheduled turn, not synchronously") + func updateWritesOnScheduledTurn() { + guard #available(iOS 13.0, *) else { return } + + var written = [EmbeddedBlockPresentation]() + var scheduled = [() -> Void]() + let coordinator = EmbeddedBlockRepresentable.Coordinator( + presentation: Binding(get: { EmbeddedBlockPresentation(layer: .placeholder, height: 104) }, + set: { written.append($0) }), + creationHeight: 104, + onLoad: nil, + onFail: nil, + schedule: { scheduled.append($0) } + ) + + let content = EmbeddedBlockPresentation(layer: .content, height: 104) + coordinator.update(content) + + #expect(written.isEmpty) + + scheduled.forEach { $0() } + + #expect(written == [content]) + } + + /// The dismantle race: the write is already queued, the view leaves the tree before it runs. + /// After `detach()` the block must stay silent — the removed view's state is no longer its + /// to write. + @Test("Detach drops a write that was already scheduled") + func detachDropsScheduledWrite() { + guard #available(iOS 13.0, *) else { return } + + var written = [EmbeddedBlockPresentation]() + var scheduled = [() -> Void]() + let coordinator = EmbeddedBlockRepresentable.Coordinator( + presentation: Binding(get: { EmbeddedBlockPresentation(layer: .placeholder, height: 104) }, + set: { written.append($0) }), + creationHeight: 104, + onLoad: nil, + onFail: nil, + schedule: { scheduled.append($0) } + ) + + coordinator.update(EmbeddedBlockPresentation(layer: .content, height: 104)) + coordinator.detach() + + scheduled.forEach { $0() } + + #expect(written.isEmpty) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift new file mode 100644 index 000000000..294e248b2 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift @@ -0,0 +1,108 @@ +// +// EmbeddedBlockLayerHostTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +/// The layer host has one promise: the container holds exactly one view, stretched to its edges. The +/// block's layers are mutually exclusive, so showing a new one means removing the previous one. +@Suite("Embedded block layer host", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockLayerHostTests { + + @Test("Shown view fills the container") + func shownViewFillsTheContainer() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + + #expect(layer.superview === container) + #expect(layer.translatesAutoresizingMaskIntoConstraints == false) + // Four edges: the layer always fills the container it was given. + #expect(container.constraints.count == 4) + } + + @Test("Showing another view replaces the first") + func showingAnotherViewReplacesTheFirst() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let first = UIView() + let second = UIView() + + host.show(first) + host.show(second) + + #expect(first.superview == nil) + #expect(second.superview === container) + #expect(container.subviews.count == 1) + #expect(container.constraints.count == 4) + } + + @Test("Showing nothing detaches the current view") + func showingNothingDetachesTheCurrentView() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + host.show(nil) + + #expect(layer.superview == nil) + #expect(container.subviews.isEmpty) + #expect(container.constraints.isEmpty) + } + + /// A collapsed block stays collapsed and keeps receiving `show(nil)` on every state change: + /// there is nothing to remove, and the host must not touch the container. + @Test("Showing nothing when nothing is shown changes nothing") + func showingNothingOnEmptyHostChangesNothing() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + + host.show(nil) + host.show(nil) + + #expect(container.subviews.isEmpty) + #expect(container.constraints.isEmpty) + } + + /// The container calls `show` on every state change, and some of those calls come with the same + /// view. There is no reason to rebuild its constraints — they would simply keep piling up. + @Test("Showing the same view again changes nothing") + func showingTheSameViewAgainChangesNothing() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + host.show(layer) + host.show(layer) + + #expect(container.subviews.count == 1) + #expect(container.constraints.count == 4) + } + + /// The view to remove is the one that is actually attached: if it was detached from outside, + /// showing it again must put it back rather than decide it is already there. + @Test("A view detached from outside is attached again") + func viewDetachedFromOutsideIsAttachedAgain() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + layer.removeFromSuperview() + host.show(layer) + + #expect(layer.superview === container) + #expect(container.constraints.count == 4) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift new file mode 100644 index 000000000..6262910de --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -0,0 +1,254 @@ +// +// EmbeddedBlockMocks.swift +// MindboxTests +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +@testable import Mindbox + +extension EmbeddedBlockWebContent { + + static let stub = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/block.html")!) + + static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!) +} + +extension EmbeddedBlockPageAction { + + static let openUrlStub = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) +} + +/// A page without WebKit: tests decide what it tells the native side and when. +final class EmbeddedBlockPageMock: EmbeddedBlockPageHosting { + + let view = UIView() + + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + + var onLoadFailure: (() -> Void)? + + var onLoadFinish: (() -> Void)? + + var loadCount = 0 + var cancelCount = 0 + + func load() { + loadCount += 1 + } + + func cancel() { + cancelCount += 1 + } + + func send(_ message: EmbeddedBlockPageMessage) { + onMessage?(message) + } + + func failLoad() { + onLoadFailure?() + } + + func finishLoad() { + onLoadFinish?() + } +} + +final class EmbeddedBlockReadinessOverridesMock: EmbeddedBlockReadinessOverriding { + + var treatsLoadedPageAsReady: Bool + + init(treatsLoadedPageAsReady: Bool = false) { + self.treatsLoadedPageAsReady = treatsLoadedPageAsReady + } +} + +/// Counts how many pages were made and with what content: a reload must make a new one. +final class EmbeddedBlockPageFactoryMock { + + private(set) var pages: [EmbeddedBlockPageMock] = [] + private(set) var contents: [EmbeddedBlockWebContent] = [] + + var page: EmbeddedBlockPageMock? { pages.last } + + func make(_ content: EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting { + contents.append(content) + let page = EmbeddedBlockPageMock() + pages.append(page) + return page + } +} + +final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { + + var resolution: EmbeddedBlockResolution + + /// `true` — the answer does not arrive until the test calls `flush()`: this is how a resolve + /// that lands after the block was stopped or reloaded is checked. + var isDeferred = false + + private(set) var resolvedIds: [String] = [] + private(set) var forceRefreshHistory: [Bool] = [] + + var resolveCount: Int { resolvedIds.count } + + private var pending: [(EmbeddedBlockResolution) -> Void] = [] + + init(resolution: EmbeddedBlockResolution = .content(.stub)) { + self.resolution = resolution + } + + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + resolvedIds.append(id) + forceRefreshHistory.append(forceRefresh) + + if isDeferred { + pending.append(completion) + } else { + completion(resolution) + } + } + + func flush() { + let completions = pending + pending = [] + completions.forEach { $0(resolution) } + } +} + +final class EmbeddedBlockActionHandlerMock: EmbeddedBlockActionHandling { + + private(set) var handledActions: [EmbeddedBlockPageAction] = [] + + func handle(_ action: EmbeddedBlockPageAction) { + handledActions.append(action) + } +} + +/// A clock that moves only when asked to. Monotonic seconds, matching the timeout's clock seam; +/// a negative `advance` models the backward jump a monotonic clock never makes. +final class TestClock { + + private(set) var now: TimeInterval = 1_000_000 + + func advance(_ seconds: TimeInterval) { + now += seconds + } +} + +/// A scheduler that never fires on its own: "time is up" is declared by the test. +/// +/// Thanks to it the waiting budget is checked without a single sleep: both in its own tests and in +/// the tests of the container, which is handed the budget from outside. +final class TestScheduler { + + /// The delay of the last arm — which is the remainder of the budget given to the countdown. + private(set) var lastDelay: TimeInterval? + + private var pending: [DispatchWorkItem] = [] + + func schedule(_ delay: TimeInterval, _ work: DispatchWorkItem) { + lastDelay = delay + pending.append(work) + } + + /// Performs the armed work, skipping what was cancelled: `pause()` and `reset()` cancel it + /// exactly the way they would cancel work on a real queue. + func fireAll() { + let scheduled = pending + pending = [] + scheduled.forEach { work in + guard !work.isCancelled else { return } + + work.perform() + } + } +} + +/// The waiting budget with a substituted clock, scheduler and notification center — everything +/// that makes it different from the real one, gathered in one place. +final class EmbeddedBlockTimeoutBed { + + let clock: TestClock + let scheduler: TestScheduler + + /// One per bed: the background and the return from it must reach only this budget. + let center: NotificationCenter + + let timeout: EmbeddedBlockReadyTimeout + + init(blockId: String = "block-id", duration: TimeInterval = 5) { + let clock = TestClock() + let scheduler = TestScheduler() + let center = NotificationCenter() + self.clock = clock + self.scheduler = scheduler + self.center = center + timeout = EmbeddedBlockReadyTimeout(blockId: blockId, + duration: duration, + now: { clock.now }, + notificationCenter: center, + schedule: { scheduler.schedule($0, $1) }) + } + + func enterBackground() { + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func enterForeground() { + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } +} + +/// The provider with all dependencies substituted — the shared rig for the provider and container +/// tests. The container is tested through a real provider: the single seam inside the block is the +/// page, and there is nothing else to substitute. +final class EmbeddedBlockTestBed { + + let resolver: EmbeddedBlockResolverMock + let actionHandler: EmbeddedBlockActionHandlerMock + let readinessOverrides: EmbeddedBlockReadinessOverridesMock + let pageFactory: EmbeddedBlockPageFactoryMock + let provider: EmbeddedBlockWebViewProvider + + var page: EmbeddedBlockPageMock? { pageFactory.page } + + init(id: String = "block-id", + resolution: EmbeddedBlockResolution = .content(.stub), + treatsLoadedPageAsReady: Bool = false) { + let resolver = EmbeddedBlockResolverMock(resolution: resolution) + let actionHandler = EmbeddedBlockActionHandlerMock() + let readinessOverrides = EmbeddedBlockReadinessOverridesMock(treatsLoadedPageAsReady: treatsLoadedPageAsReady) + let pageFactory = EmbeddedBlockPageFactoryMock() + + self.resolver = resolver + self.actionHandler = actionHandler + self.readinessOverrides = readinessOverrides + self.pageFactory = pageFactory + self.provider = EmbeddedBlockWebViewProvider(id: id, + resolver: resolver, + actionHandler: actionHandler, + readinessOverrides: readinessOverrides, + makePage: { pageFactory.make($0) }) + } +} + +final class EmbeddedBlockViewDelegateMock: MindboxEmbeddedBlockViewDelegate { + + enum Event: Equatable { + case loaded + case failed + } + + private(set) var events: [Event] = [] + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + events.append(.loaded) + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + events.append(.failed) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift new file mode 100644 index 000000000..b3884d84b --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift @@ -0,0 +1,272 @@ +// +// EmbeddedBlockReadyTimeoutTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +import UIKit +@testable import Mindbox + +/// Neither the clock nor the scheduler here is real. How much of the budget is "already spent" the +/// tests dictate with the substituted clock, and "time is up" happens on their command. Nothing is +/// waited out in real time: the budget is arithmetic over what was spent, and checking it with a +/// stopwatch would mean paying half a second per test and flaking on a busy runner. +/// +/// Hence the main assertion of most tests — not "expired or not", but the delay the countdown was +/// armed with: that delay is the remainder of the budget. +/// +/// Going to the background and returning go through each bed's own notification center: on the +/// global one such a notification would reach the blocks of tests running next to it. +private let budget: TimeInterval = 0.4 + +@Suite("Embedded block ready timeout", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockReadyTimeoutTests { + + @Test("A budget that is never paused expires on its own") + func unpausedBudgetExpires() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// While nobody is waiting for the block, the budget is not spent and does not expire. + @Test("A paused budget does not expire") + func pausedBudgetDoesNotExpire() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.pause() + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + #expect(bed.timeout.isRunning == false) + } + + /// The main point: a pause stops the count, it does not start it over. Almost everything is + /// spent, so after resuming the countdown is armed for the tiny remainder — not the full budget. + @Test("Resuming continues the remaining budget instead of granting a new one") + func resumeContinuesTheRemainder() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.timeout.pause() + + bed.timeout.armIfNeeded() + + #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// Exactly the scenario that makes a resetting pause unusable: a user flipping between apps + /// must not be able to stretch the block's wait indefinitely. + @Test("Repeated pause and resume cannot stretch the budget past its duration") + func repeatedPausesCannotStretchTheBudget() { + let bed = TimeoutBed() + + for _ in 0..<5 { + bed.timeout.armIfNeeded() + bed.clock.advance(budget / 4) + bed.timeout.pause() + } + + #expect(bed.expirations == 0) + + // Five stretches of a quarter each — the budget is spent in full, and the next arm does + // not give the block a single second more. + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == 0) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// A new attempt is another matter: it is waited for with the full budget. + @Test("Reset gives the next attempt a full budget again") + func resetGrantsAFullBudget() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.timeout.reset() + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + @Test("Reset stops a running budget") + func resetStopsTheCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.reset() + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + /// The budget is needed only while the outcome is unknown and the block is visible — the + /// container knows that, and its answer is asked on every arm. + @Test("A budget nobody needs is not armed at all") + func unneededBudgetIsNotArmed() { + let bed = TimeoutBed(isNeeded: false) + + bed.timeout.armIfNeeded() + + #expect(bed.timeout.isRunning == false) + #expect(bed.scheduler.lastDelay == nil) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + // MARK: - Clock + + /// A clock jumping backwards must not shrink what was spent: the stretch counts as zero at + /// worst, and the block never waits past its budget. The real clock is monotonic and cannot + /// jump, so the guard matters for the seam and against regressions back to `Date`. + @Test("A clock that jumps backwards does not shrink the spent budget") + func backwardClockJumpDoesNotShrinkSpentBudget() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(-100) + bed.timeout.pause() + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + // MARK: - Background + + /// While the app is in the background nobody waits for the block — so the budget must not be + /// spent either. + @Test("Going to the background pauses a running budget") + func backgroundPausesTheCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.enterBackground() + + #expect(bed.timeout.isRunning == false) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + /// And exactly what the spent-time accounting exists for: returning from the background + /// continues the budget from the remainder instead of granting it anew. + @Test("Returning from the background continues the remaining budget") + func foregroundContinuesTheRemainder() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.enterBackground() + bed.enterForeground() + + #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// A background that finds the block outside a countdown cannot spend anything: the next + /// attempt gets the budget in full. + @Test("Going to the background outside a countdown consumes nothing") + func backgroundOutsideCountdownConsumesNothing() { + let bed = TimeoutBed() + + bed.enterBackground() + bed.clock.advance(budget) + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + /// Returning from the background to a block nobody waits for does not resurrect the countdown: + /// whether it is needed is the container's call, asked on every arm. + @Test("Returning from the background does not arm a budget nobody needs") + func foregroundDoesNotArmAnUnneededBudget() { + let bed = TimeoutBed(isNeeded: false) + + bed.enterForeground() + + #expect(bed.timeout.isRunning == false) + #expect(bed.scheduler.lastDelay == nil) + } + + // MARK: - Arming + + /// Arming is idempotent: entering the window, returning from the background and reloading call + /// it in any order, and a second call must not start a second countdown. + @Test("Arming twice runs a single countdown") + func armingTwiceRunsOneCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.armIfNeeded() + bed.scheduler.fireAll() + + // Had two countdowns been armed, there would be as many expirations. + #expect(bed.expirations == 1) + } +} + +/// The budget remainder is arithmetic over `Double`, so it is compared with a tolerance. +private func isClose(_ value: TimeInterval?, to expected: TimeInterval) -> Bool { + guard let value else { return false } + + return abs(value - expected) < 0.0001 +} + +/// The shared budget bed plus an expiration counter: here the budget is tested on its own, so +/// `isNeeded` is set by the test directly instead of being asked from the container. +@MainActor +private final class TimeoutBed { + + private let bed = EmbeddedBlockTimeoutBed(duration: budget) + + private(set) var expirations = 0 + + var timeout: EmbeddedBlockReadyTimeout { bed.timeout } + var clock: TestClock { bed.clock } + var scheduler: TestScheduler { bed.scheduler } + + init(isNeeded: Bool = true) { + bed.timeout.isNeeded = { isNeeded } + bed.timeout.onExpire = { [weak self] in + self?.expirations += 1 + } + } + + func enterBackground() { + bed.enterBackground() + } + + func enterForeground() { + bed.enterForeground() + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift new file mode 100644 index 000000000..4cc2be4d1 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -0,0 +1,258 @@ +// +// EmbeddedBlockResolverTests.swift +// MindboxTests +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +@Suite("Embedded block resolver", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockResolverTests { + + /// The resolver's main promise: however many blocks ask about one id, we go for the data once. + /// While the config is synchronous this is invisible; over the network it is the difference + /// between one request and N. + @Test("Blocks asking for the same id at once share a single load") + func concurrentResolvesShareOneLoad() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + var answers: [EmbeddedBlockResolution] = [] + + resolver.resolve("promo") { answers.append($0) } + resolver.resolve("promo") { answers.append($0) } + resolver.resolve("promo") { answers.append($0) } + + #expect(loader.requestedIds == ["promo"]) + #expect(answers.isEmpty) + + loader.answer(.content(.stub)) + + #expect(answers == [.content(.stub), .content(.stub), .content(.stub)]) + } + + @Test("Different ids are loaded separately") + func differentIdsAreLoadedSeparately() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + + resolver.resolve("promo") { _ in } + resolver.resolve("stories") { _ in } + + #expect(loader.requestedIds == ["promo", "stories"]) + } + + @Test("Answered id comes from the cache next time") + func answeredIdIsCached() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + resolver.resolve("promo") { _ in } + loader.answer(.content(.stub)) + + var answer: EmbeddedBlockResolution? + resolver.resolve("promo") { answer = $0 } + + #expect(loader.requestedIds == ["promo"]) + #expect(answer == .content(.stub)) + } + + /// A block reload must not keep pulling the old address from the cache: a block that was turned + /// off or moved would otherwise stay broken until the app is restarted. + @Test("Force refresh asks for the data again and replaces the cache") + func forceRefreshBypassesTheCache() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + resolver.resolve("promo") { _ in } + loader.answer(.content(.stub)) + + var refreshed: EmbeddedBlockResolution? + resolver.resolve("promo", forceRefresh: true) { refreshed = $0 } + loader.answer(.empty) + + #expect(loader.requestedIds == ["promo", "promo"]) + #expect(refreshed == .empty) + + var cached: EmbeddedBlockResolution? + resolver.resolve("promo") { cached = $0 } + #expect(cached == .empty) + } + + /// The real config will answer from a background thread. The cache, the queue of waiters and the + /// block view live on the main one, so the answer has to move there instead of being handled + /// wherever it was delivered. + @Test("An answer from a background thread is delivered on the main thread") + func backgroundAnswerIsDeliveredOnTheMainThread() async { + let resolver = EmbeddedBlockResolver( + load: { _, completion in + DispatchQueue.global().async { completion(.content(.stub)) } + }, + overrides: EmbeddedBlockContentOverrides() + ) + + let deliveredOnMainThread: Bool = await withCheckedContinuation { continuation in + resolver.resolve("promo") { _ in + continuation.resume(returning: Thread.isMainThread) + } + } + + #expect(deliveredOnMainThread) + + // And the cache is already filled on the main thread: the next block gets the answer at once. + var cached: EmbeddedBlockResolution? + resolver.resolve("promo") { cached = $0 } + #expect(cached == .content(.stub)) + } + + // MARK: - Debug overrides + + /// Acceptance testing switches scenarios on the fly, so the override outranks both the load and + /// the cache. + @Test("Debug override answers instead of the data and outranks the cache") + func overrideOutranksEverything() { + let loader = ContentLoaderSpy() + let overrides = EmbeddedBlockContentOverrides() + let resolver = EmbeddedBlockResolver(load: loader.load, overrides: overrides) + resolver.resolve("promo") { _ in } + loader.answer(.content(.stub)) + + overrides.set(.empty, for: "promo") + var answers: [EmbeddedBlockResolution] = [] + resolver.resolve("promo") { answers.append($0) } + resolver.resolve("promo") { answers.append($0) } + + #expect(answers == [.empty, .empty]) + // The resolver did not go for the data: the answer came from the override. + #expect(loader.requestedIds == ["promo"]) + } + + @Test("Removing the override brings the real content back") + func removingOverrideRestoresContent() { + let loader = ContentLoaderSpy() + let overrides = EmbeddedBlockContentOverrides() + let resolver = EmbeddedBlockResolver(load: loader.load, overrides: overrides) + overrides.set(.empty, for: "promo") + resolver.resolve("promo") { _ in } + + overrides.remove(for: "promo") + var answer: EmbeddedBlockResolution? + resolver.resolve("promo") { answer = $0 } + loader.answer(.content(.stub)) + + #expect(loader.requestedIds == ["promo"]) + #expect(answer == .content(.stub)) + } + + @Test("Override applies only to its own id") + func overrideAppliesToItsIdOnly() { + let loader = ContentLoaderSpy() + let overrides = EmbeddedBlockContentOverrides() + let resolver = EmbeddedBlockResolver(load: loader.load, overrides: overrides) + overrides.set(.empty, for: "promo") + + var answer: EmbeddedBlockResolution? + resolver.resolve("promo") { _ in } + resolver.resolve("stories") { answer = $0 } + loader.answer(.content(.stub)) + + #expect(loader.requestedIds == ["stories"]) + #expect(answer == .content(.stub)) + } + + @Test("Overridden content carries the page markup as it was given") + func overrideCarriesMarkup() { + let overrides = EmbeddedBlockContentOverrides() + overrides.set(.content(EmbeddedBlockWebContent(html: "empty page")), for: "promo") + + guard case .content(let content) = overrides.resolution(for: "promo"), + case .html(let html) = content.source else { + Issue.record("Expected the override to carry inline html") + return + } + #expect(html == "empty page") + } + + /// The stub in place of the config: while there is none, any id leads to the stories feed page. + @Test("The stubbed loader resolves any id to the stories page") + func stubbedLoaderResolvesToTheStoriesPage() { + var resolution: EmbeddedBlockResolution? + + EmbeddedBlockResolver.loadStubbedStoriesPage("whatever") { resolution = $0 } + + guard case .content(let content) = resolution, case .url(let url) = content.source else { + Issue.record("Expected the stub to resolve into a page url, got \(String(describing: resolution))") + return + } + #expect(url.absoluteString.hasSuffix("stories.html")) + } +} + +/// A loader that answers only when asked to: this is how the resolver's behaviour while a load is +/// still in flight gets tested. +private final class ContentLoaderSpy { + + private(set) var requestedIds: [String] = [] + + private var completions: [(EmbeddedBlockResolution) -> Void] = [] + + func load(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + requestedIds.append(id) + completions.append(completion) + } + + func answer(_ resolution: EmbeddedBlockResolution) { + let pending = completions + completions = [] + pending.forEach { $0(resolution) } + } + + /// Answers from a background queue — how the real config, parsed off the main thread, will answer. + func answerOffMain(_ resolution: EmbeddedBlockResolution) { + let pending = completions + completions = [] + DispatchQueue.global().async { + pending.forEach { $0(resolution) } + } + } +} + +/// Which thread the resolver delivered the answer on. A separate type instead of `Bool` — so that +/// a failing test says right away what exactly diverged. +private enum DeliveryThread { + case main + case other +} + +/// Waits for the resolver's answers and remembers which thread each one arrived on. +/// +/// It is read and written only from the main thread — if that stops being true, the test will fail +/// precisely on `threads`. +private final class DeliveryRecorder { + + private(set) var answers: [EmbeddedBlockResolution] = [] + private(set) var threads: [DeliveryThread] = [] + + private var expectedCount = 0 + private var continuation: CheckedContinuation? + + func record(_ resolution: EmbeddedBlockResolution) { + answers.append(resolution) + threads.append(Thread.isMainThread ? .main : .other) + + guard answers.count >= expectedCount, let continuation else { return } + self.continuation = nil + continuation.resume() + } + + /// The waiter itself starts the load: start it any earlier — and the answer could arrive before + /// the test started waiting, and the wait would hang forever. + func waitForAnswers(count: Int, _ startLoading: () -> Void) async { + expectedCount = count + await withCheckedContinuation { continuation in + self.continuation = continuation + startLoading() + } + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift new file mode 100644 index 000000000..f8579a7c1 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -0,0 +1,123 @@ +// +// EmbeddedBlockWebViewPageTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import WebKit +@testable import Mindbox + +/// The page only judges its own business: the load failed or the document arrived. It is exactly +/// this separation that is checked here — and above all, that a cancelled navigation does not end up +/// among the failures. +@Suite("Embedded block web view page", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockWebViewPageTests { + + /// A navigation is cancelled in two perfectly ordinary cases: it was superseded by a client-side + /// redirect, and it was stopped by our own `cancel()` on a block that went off screen. Neither + /// means the block is broken — while a failure collapses it for good. + @Test("A cancelled provisional navigation is not a load failure") + func cancelledProvisionalNavigationIsNotAFailure() { + let bed = PageBed() + + bed.failProvisionalNavigation(with: bed.cancellationError) + + #expect(bed.failures == 0) + } + + @Test("A cancelled navigation is not a load failure either") + func cancelledNavigationIsNotAFailure() { + let bed = PageBed() + + bed.failNavigation(with: bed.cancellationError) + + #expect(bed.failures == 0) + } + + /// A real network error, on the other hand, is exactly what the page must report. + @Test("A real provisional navigation error is a load failure") + func realProvisionalErrorIsAFailure() { + let bed = PageBed() + + bed.failProvisionalNavigation(with: bed.error(code: NSURLErrorNotConnectedToInternet)) + + #expect(bed.failures == 1) + } + + @Test("A real navigation error is a load failure") + func realNavigationErrorIsAFailure() { + let bed = PageBed() + + bed.failNavigation(with: bed.error(code: NSURLErrorTimedOut)) + + #expect(bed.failures == 1) + } + + /// Cancelling one navigation must not mute the page: the next real error arrives as usual. + @Test("A cancellation does not swallow the failure that comes after it") + func cancellationDoesNotSwallowLaterFailures() { + let bed = PageBed() + + bed.failProvisionalNavigation(with: bed.cancellationError) + bed.failProvisionalNavigation(with: bed.error(code: NSURLErrorCannotFindHost)) + + #expect(bed.failures == 1) + } + + @Test("A finished document is reported as a finish, not as a failure") + func finishedDocumentIsReportedAsFinish() { + let bed = PageBed() + + bed.finishNavigation() + + #expect(bed.finishes == 1) + #expect(bed.failures == 0) + } +} + +/// A real page with a real web view, but without the network: the tests call the navigation delegate +/// methods themselves — it is their handling that is checked here. +@MainActor +private final class PageBed { + + let page: EmbeddedBlockWebViewPage + + private(set) var failures = 0 + private(set) var finishes = 0 + + var cancellationError: Error { error(code: NSURLErrorCancelled) } + + /// Through the protocol rather than directly: the page has both a `webView` property and + /// delegate methods with the same name, so they are better called where the name is unambiguous. + private var navigation: WKNavigationDelegate { page } + + init() { + page = EmbeddedBlockWebViewPage(content: .stub, webView: WKWebView()) + page.onLoadFailure = { [weak self] in + self?.failures += 1 + } + page.onLoadFinish = { [weak self] in + self?.finishes += 1 + } + } + + func error(code: Int) -> Error { + NSError(domain: NSURLErrorDomain, code: code) + } + + func failProvisionalNavigation(with error: Error) { + navigation.webView?(page.webView, didFailProvisionalNavigation: nil, withError: error) + } + + func failNavigation(with error: Error) { + navigation.webView?(page.webView, didFail: nil, withError: error) + } + + func finishNavigation() { + navigation.webView?(page.webView, didFinish: nil) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift new file mode 100644 index 000000000..29e7eb910 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift @@ -0,0 +1,556 @@ +// +// EmbeddedBlockWebViewProviderTests.swift +// MindboxTests +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("Embedded block web view provider", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockWebViewProviderTests { + + // MARK: - Loading + + @Test("Start resolves the id and loads the resolved content") + func startResolvesAndLoads() { + let bed = EmbeddedBlockTestBed(id: "promo") + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(bed.resolver.resolvedIds == ["promo"]) + #expect(bed.pageFactory.contents == [.stub]) + #expect(bed.page?.loadCount == 1) + #expect(states == [.loading]) + // Before the page is ready there is no content: the container has nothing to show. + #expect(bed.provider.contentView == nil) + } + + @Test("Second start does not resolve or load again") + func secondStartDoesNothing() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.start() + + #expect(bed.resolver.resolveCount == 1) + #expect(bed.page?.loadCount == 1) + } + + /// A block switched off in the admin panel or unknown to it is not an error: no page is even + /// created for it. + @Test("Empty resolution needs no page at all") + func emptyResolutionCreatesNoPage() { + let bed = EmbeddedBlockTestBed(resolution: .empty) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading, .empty]) + #expect(bed.pageFactory.pages.isEmpty) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Readiness + + /// Only the page itself reports readiness — it is the single source of truth. + @Test("Page ready makes the content available") + func pageReadyMakesContentAvailable() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + #expect(states == [.loading, .ready]) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// A silent page never becomes ready on its own: a loaded document says nothing about whether + /// the block has anything to show. Such a block will be finished off by the container's + /// timeout. + @Test("Silent page never becomes ready on its own") + func silentPageStaysLoading() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading]) + #expect(bed.provider.contentView == nil) + } + + /// A page with no content is more honest saying `empty`, so a zero height is broken layout. + @Test("Zero height in ready is a failure") + func zeroHeightIsFailure() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 0)) + + #expect(states.last == .failed) + #expect(bed.provider.contentView == nil) + } + + /// The host owns the height: the message exists in the contract, but it does not touch layout. + @Test("Height change leaves the state alone") + func heightChangeChangesNothing() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.send(.ready(height: 104)) + bed.page?.send(.heightChanged(height: 132)) + + #expect(states == [.ready]) + } + + @Test("Page empty collapses the block") + func pageEmptyCollapsesTheBlock() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.send(.ready(height: 104)) + bed.page?.send(.empty) + + #expect(states == [.ready, .empty]) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Debug readiness + + /// The usual rule: a loaded document says nothing about whether the block has anything to show. + @Test("Loaded document alone does not make the block ready") + func loadFinishAloneChangesNothing() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + + #expect(states == [.loading]) + #expect(bed.provider.contentView == nil) + } + + /// With the override on the block is shown on a loaded document — this is how UI is checked + /// while the page cannot yet send `ready`. + @Test("With the debug override a loaded document shows the block") + func loadFinishMakesBlockReadyWithOverride() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + + #expect(states == [.loading, .ready]) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// A page that implements the contract behaves the same with the override as without it: + /// `ready` has already shown the block, and the document does not add a second showing. + @Test("A page that sent ready is not shown twice by the override") + func readyBeforeLoadFinishIsNotDuplicated() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 104)) + bed.page?.finishLoad() + + #expect(states == [.loading, .ready]) + } + + /// The override is not stronger than the page: its "nothing to show" collapses the block even + /// with the flag on. + @Test("The override does not swallow an empty from the page") + func overrideDoesNotSwallowEmpty() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + bed.page?.send(.empty) + + #expect(states == [.loading, .ready, .empty]) + #expect(bed.provider.contentView == nil) + } + + /// After `stop()` the provider stays silent entirely — the override does not change that. + @Test("Loaded document after a stop is ignored even with the override") + func loadFinishAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + bed.provider.start() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.finishLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// A page dropped by a reload must not show itself through the override either. + @Test("The dropped page cannot show itself through the override") + func droppedPageCannotFinishIntoTheNewAttempt() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + bed.provider.start() + let firstPage = bed.page + bed.provider.reload() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + firstPage?.finishLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Load failure + + /// A load failure is the only thing navigation judges. + @Test("Load failure fails the block") + func loadFailureFailsTheBlock() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.failLoad() + + #expect(states == [.loading, .failed]) + #expect(bed.provider.contentView == nil) + } + + @Test("Load failure after a stop is ignored") + func loadFailureAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.failLoad() + + #expect(states.isEmpty) + } + + // MARK: - Page actions + + /// The core does not know the page's dictionary: everything above the core layer goes to the + /// handler as is and does not touch the container's state. + @Test("Page action is routed to the handler and changes no state") + func actionIsRouted() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + let action = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) + bed.page?.send(.action(action)) + + #expect(bed.actionHandler.handledActions == [action]) + #expect(states.isEmpty) + } + + /// A stopped provider stays silent entirely — including not waking the action handler. + @Test("Actions after a stop do not reach the handler") + func actionsAfterStopAreIgnored() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.stop() + bed.page?.send(.action(EmbeddedBlockPageAction(type: "openUrl", payload: [:]))) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + @Test("Action from a shown block is routed") + func actionFromShownBlockIsRouted() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions == [.openUrlStub]) + } + + /// A collapsed block does not kill the page — it stays alive and may still deliver what it + /// scheduled. But there is no user touch behind an invisible block, and `openUrl` would take + /// the user out of the app for no reason. + @Test("Actions from a block collapsed as empty do not reach the handler") + func actionsAfterEmptyAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.send(.empty) + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + @Test("Actions from a failed block do not reach the handler") + func actionsAfterFailureAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.failLoad() + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + /// Broken layout is the same as a block not shown: its actions are not executed either. + @Test("Actions from a block broken by a zero height do not reach the handler") + func actionsAfterZeroHeightAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.send(.ready(height: 0)) + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + /// The ban rests on the attempt's outcome, not on the page: a new attempt is live again. + @Test("A new attempt after a failure accepts actions again") + func retryAfterFailureAcceptsActions() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.failLoad() + + bed.provider.stop() + bed.provider.start() + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions == [.openUrlStub]) + } + + // MARK: - Stop and restart + + /// After `stop()` the provider must stay silent — the container relies on this when it + /// collapses expired content on its own timeout. + @Test("Stop cancels the page and ignores what it says afterwards") + func stopCancelsThePage() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.stop() + bed.page?.send(.ready(height: 104)) + + #expect(bed.page?.cancelCount == 1) + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// The container calls `start()` every time it returns to the window: there is no need to + /// recreate the web view and ask the resolver again on every return. + @Test("Restart reuses the same page without resolving again") + func restartReusesThePage() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.stop() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + #expect(bed.resolver.resolveCount == 1) + #expect(bed.pageFactory.pages.count == 1) + #expect(bed.page?.loadCount == 2) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// The block left the screen already shown — on return it must not load again: the page + /// remained in memory, and it is shown as is. + @Test("Page rendered before the block left the window is shown again without a reload") + func renderedPageIsShownAgainWithoutReload() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.ready]) + #expect(bed.page?.loadCount == 1) + #expect(bed.resolver.resolveCount == 1) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// But a block that failed to show gets a new attempt on return — this is the only retry the + /// block has for now. + @Test("Failed block tries again when it comes back") + func failedBlockTriesAgainOnReturn() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.failLoad() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading]) + #expect(bed.page?.loadCount == 2) + } + + // MARK: - Live blocks + + /// The live-block counter is shared across the process, so each test uses its own id: + /// otherwise tests running in parallel would count each other's blocks. + @Test("Live count follows the life of a block") + func liveCountFollowsBlockLife() { + let id = "live-count-single" + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + + do { + let provider = makeProvider(id: id) + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) + } + } + + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + } + + @Test("Blocks sharing an id are counted together") + func liveCountSumsBlocksOfTheSameId() { + let id = "live-count-shared" + + do { + let first = makeProvider(id: id) + let second = makeProvider(id: id) + withExtendedLifetime((first, second)) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) + } + } + + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + } + + @Test("Blocks with different ids are counted apart") + func liveCountKeepsIdsApart() { + let promo = "live-count-promo" + let stories = "live-count-stories" + + let provider = makeProvider(id: promo) + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: promo) == 1) + #expect(EmbeddedBlockWebViewProvider.liveCount(for: stories) == 0) + } + } + + private func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { + EmbeddedBlockWebViewProvider(id: id, + resolver: EmbeddedBlockResolverMock(), + actionHandler: EmbeddedBlockActionHandlerMock(), + makePage: { _ in EmbeddedBlockPageMock() }) + } + + /// The resolve may have arrived after the stop — then it belongs to the previous attempt. + @Test("Resolution arriving after a stop creates nothing") + func lateResolutionAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.provider.stop() + bed.resolver.flush() + + #expect(bed.pageFactory.pages.isEmpty) + #expect(states == [.loading]) + } + + // MARK: - Reload + + @Test("Reload asks for the content again bypassing the cache and builds a new page") + func reloadRefetchesTheContent() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + let firstPage = bed.page + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.resolver.resolution = .content(.other) + bed.provider.reload() + + #expect(bed.resolver.forceRefreshHistory == [false, true]) + #expect(bed.pageFactory.contents == [.stub, .other]) + #expect(bed.pageFactory.pages.count == 2) + #expect(bed.page !== firstPage) + #expect(firstPage?.cancelCount == 1) + #expect(states == [.loading]) + // Readiness starts from zero: the new page has said nothing yet. + #expect(bed.provider.contentView == nil) + } + + /// The old page is no longer relevant — its late messages must not show the dropped content. + @Test("The dropped page cannot report into the new attempt") + func droppedPageIsSilenced() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + let firstPage = bed.page + bed.provider.reload() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + firstPage?.send(.ready(height: 104)) + firstPage?.failLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// The previous attempt's resolve must not replace the new page. + @Test("Resolution arriving after a reload does not add a second page") + func lateResolutionAfterReloadIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + bed.provider.start() + + bed.provider.reload() + bed.resolver.flush() + + #expect(bed.resolver.resolveCount == 2) + #expect(bed.pageFactory.pages.count == 1) + } + + @Test("Reloaded block becomes ready through the same path") + func reloadedBlockBecomesReady() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + bed.provider.reload() + bed.page?.send(.ready(height: 104)) + + #expect(bed.provider.contentView === bed.page?.view) + } +} diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift new file mode 100644 index 000000000..9367fbd9a --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift @@ -0,0 +1,244 @@ +// +// MindboxEmbeddedBlockTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +#if canImport(SwiftUI) +import Testing +import SwiftUI +@testable import Mindbox + +/// The block logic lives in the UIKit container, so only what the SwiftUI wrapper owns is checked +/// here: the modifier contract and the way the wrapper sets the container up for the layers it +/// draws itself. The deferred presentation writes are covered by `EmbeddedBlockCoordinatorTests`. +/// +/// The suite is not marked `@available(iOS 13.0, *)` — the `@Suite`/`@Test` macros reject such +/// declarations. The test target builds for iOS 12, so each test opens SwiftUI availability for +/// itself with `guard #available`, and helpers carry the annotation. +@Suite("MindboxEmbeddedBlock SwiftUI wrapper", .tags(.embeddedBlocks)) +@MainActor +struct MindboxEmbeddedBlockTests { + + // MARK: - Modifiers + + /// The modifier contract rests on value semantics: a modifier must return a new block, not + /// mutate the one it was applied to, otherwise the same block reused in a layout with + /// different dressing would drag someone else's placeholder along. + @Test("Bare block has neither a placeholder nor an error view") + func bareBlockHasNoCustomViews() { + guard #available(iOS 13.0, *) else { return } + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + + #expect(block.placeholderBuilder == nil) + #expect(block.errorBuilder == nil) + } + + @Test("Placeholder modifier sets the placeholder and leaves the error view alone") + func placeholderModifierSetsOnlyThePlaceholder() { + guard #available(iOS 13.0, *) else { return } + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { Color.gray } + + #expect(block.placeholderBuilder != nil) + #expect(block.errorBuilder == nil) + } + + @Test("Error view modifier sets the error view and leaves the placeholder alone") + func errorViewModifierSetsOnlyTheErrorView() { + guard #available(iOS 13.0, *) else { return } + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .errorView { Text("no stories") } + + #expect(block.errorBuilder != nil) + #expect(block.placeholderBuilder == nil) + } + + @Test("Both modifiers compose in either order") + func bothModifiersCompose() { + guard #available(iOS 13.0, *) else { return } + + let placeholderFirst = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { Color.gray } + .errorView { Text("no stories") } + + let errorFirst = MindboxEmbeddedBlock(id: "stories", height: 104) + .errorView { Text("no stories") } + .placeholder { Color.gray } + + #expect(placeholderFirst.placeholderBuilder != nil) + #expect(placeholderFirst.errorBuilder != nil) + #expect(errorFirst.placeholderBuilder != nil) + #expect(errorFirst.errorBuilder != nil) + } + + @Test("Modifier returns a copy and does not touch the block it was applied to") + func modifierDoesNotMutateTheOriginal() { + guard #available(iOS 13.0, *) else { return } + + let bare = MindboxEmbeddedBlock(id: "stories", height: 104) + + let decorated = bare + .placeholder { Color.gray } + .errorView { Text("no stories") } + + #expect(bare.placeholderBuilder == nil) + #expect(bare.errorBuilder == nil) + #expect(decorated.placeholderBuilder != nil) + #expect(decorated.errorBuilder != nil) + } + + @Test("Applying a modifier twice keeps the last view") + func repeatedModifierKeepsTheLastView() { + guard #available(iOS 13.0, *) else { return } + + let log = BuildLog() + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { ProbeView("first", log: log) } + .placeholder { ProbeView("second", log: log) } + + _ = block.placeholderBuilder?() + + #expect(log.tags == ["second"]) + } + + // MARK: - Host layers + + /// The wrapper draws its own placeholder itself, so the container gets a transparent stand-in: + /// otherwise the SDK shimmer would show through under the host's placeholder. + @Test("Custom placeholder replaces the SDK shimmer with a transparent stand-in") + func customPlaceholderReplacesTheShimmer() throws { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable(hasPlaceholder: true).syncStandIns(in: blockView) + + let standIn = try #require(blockView.placeholderView) + #expect(standIn.superview === blockView) + #expect(blockView.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) + // Touches go to the SwiftUI layer above, not to the stand-in below it. + #expect(standIn.isUserInteractionEnabled == false) + } + + @Test("Block without a custom placeholder keeps the SDK shimmer") + func bareBlockKeepsTheShimmer() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.placeholderView == nil) + #expect(blockView.subviews.contains { $0 is EmbeddedBlockShimmerView }) + } + + /// The container agrees to keep its height on a failure only by an assigned `errorView`, so a + /// stand-in is needed here too — otherwise the SwiftUI error screen would be drawn in a + /// collapsed block. + @Test("Custom error view opts the container into showing the failure") + func customErrorViewOptsIntoShowingTheFailure() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable(hasErrorView: true).syncStandIns(in: blockView) + + #expect(blockView.errorView != nil) + } + + @Test("Block without a custom error view leaves the container collapsing") + func bareBlockLeavesTheContainerCollapsing() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.errorView == nil) + } + + /// A modifier may be applied conditionally: a layer that appeared after the block was created + /// must reach the container, and one that disappeared must stop holding its space. + @Test("Layers added and dropped after creation take effect") + func layersAddedAndDroppedAfterCreationTakeEffect() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + makeRepresentable().syncStandIns(in: blockView) + + makeRepresentable(hasPlaceholder: true, hasErrorView: true).syncStandIns(in: blockView) + + #expect(blockView.placeholderView != nil) + #expect(blockView.errorView != nil) + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.placeholderView == nil) + #expect(blockView.errorView == nil) + } + + /// An update without changes must not cost the container a rebuild of layers and constraints: + /// `updateUIView` is called on every pass of the host's body. + @Test("Repeated updates keep the very same stand-ins") + func repeatedUpdatesKeepTheSameStandIns() throws { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + let representable = makeRepresentable(hasPlaceholder: true, hasErrorView: true) + representable.syncStandIns(in: blockView) + let placeholder = try #require(blockView.placeholderView) + let errorView = try #require(blockView.errorView) + + representable.syncStandIns(in: blockView) + + #expect(blockView.placeholderView === placeholder) + #expect(blockView.errorView === errorView) + } + + // MARK: - Helpers + + /// A container with substituted dependencies: the wrapper loads nothing itself, its job is to + /// set the container up correctly, so no window or live content is needed here. + private func makeBlockView() -> MindboxEmbeddedBlockView { + MindboxEmbeddedBlockView(id: "stories", + height: 104, + contentProvider: EmbeddedBlockTestBed().provider) + } + + @available(iOS 13.0, *) + private func makeRepresentable(hasPlaceholder: Bool = false, + hasErrorView: Bool = false) -> EmbeddedBlockRepresentable { + let presentation = EmbeddedBlockPresentation(layer: .placeholder, height: 104) + return EmbeddedBlockRepresentable(id: "stories", + height: 104, + presentation: .constant(presentation), + onLoad: nil, + onFail: nil, + hasPlaceholder: hasPlaceholder, + hasErrorView: hasErrorView) + } +} + +/// Which views the block actually built. `AnyView` cannot be looked into from outside, so the view +/// itself leaves the mark at the moment it is created. +private final class BuildLog { + var tags: [String] = [] +} + +@available(iOS 13.0, *) +private struct ProbeView: View { + + init(_ tag: String, log: BuildLog) { + log.tags.append(tag) + } + + var body: some View { Color.clear } +} +#endif diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift new file mode 100644 index 000000000..7eb03ac69 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift @@ -0,0 +1,736 @@ +// +// MindboxEmbeddedBlockViewTests.swift +// MindboxTests +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("MindboxEmbeddedBlockView container", .tags(.embeddedBlocks)) +@MainActor +struct MindboxEmbeddedBlockViewTests { + + // MARK: - Height + + /// The block's space is claimed right away: the host set the height, and it does not change + /// before the load outcome — otherwise the container would jump in the host's layout. + @Test("Loading block keeps the height given at creation") + func loadingKeepsGivenHeight() { + let block = BlockFixture() + + #expect(block.view.intrinsicContentSize.height == 120) + // Width is the host's business, the container does not declare it. + #expect(block.view.intrinsicContentSize.width == UIView.noIntrinsicMetric) + } + + @Test("Shown block keeps the same height") + func shownBlockKeepsHeight() { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// The container is the only source of height, so a host laying out by frames must get the + /// same number through the entry point it actually uses. + @Test("sizeThatFits reports the same height as intrinsicContentSize") + func sizeThatFitsMatchesIntrinsicHeight() { + let block = BlockFixture(height: 96) + + let fitted = block.view.sizeThatFits(CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude)) + + #expect(fitted.height == 96) + #expect(fitted.width == 320) + } + + @Test("Failed block collapses the container") + func failedBlockCollapses() { + let block = BlockFixture() + block.attachToWindow() + + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 0) + } + + /// The failure can be shown instead of collapsing — then the block stays the same height. + @Test("Failed block with an error view keeps its height") + func failedBlockWithErrorViewKeepsHeight() { + let block = BlockFixture() + let errorView = UIView() + block.view.errorView = errorView + block.attachToWindow() + + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(errorView.superview === block.view) + } + + /// The host has already reclaimed the collapsed block's space — reopening it after the fact + /// would jerk the layout. A late `errorView` is only remembered. + @Test("Error view assigned after the collapse does not expand the block") + func lateErrorViewDoesNotExpandCollapsedBlock() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + + let errorView = UIView() + block.view.errorView = errorView + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(errorView.superview == nil) + } + + /// A remembered `errorView` takes effect on the next load: a new attempt, a failure again — + /// and now the block shows the error instead of collapsing. + @Test("Error view assigned after the collapse applies on the next load") + func lateErrorViewAppliesOnNextLoad() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + let errorView = UIView() + block.view.errorView = errorView + + block.view.reload() + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(errorView.superview === block.view) + } + + /// An empty block always collapses: there is nothing to show, and there was no failure. + @Test("Empty block collapses even with an error view set") + func emptyBlockAlwaysCollapses() { + let block = BlockFixture() + block.view.errorView = UIView() + block.attachToWindow() + + block.page?.send(.empty) + + #expect(block.view.intrinsicContentSize.height == 0) + } + + /// A host that asked for a negative height must not get an unsatisfiable set of constraints. + @Test("Negative height given by the host is clamped to zero") + func negativeHeightIsClamped() { + let block = BlockFixture(height: -50) + + #expect(block.view.intrinsicContentSize.height == 0) + } + + // MARK: - Content view + + @Test("Shown content is attached and pinned to the container") + func shownContentIsPinned() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + + let content = try #require(block.page?.view) + #expect(content.superview === block.view) + #expect(content.translatesAutoresizingMaskIntoConstraints == false) + // Four edges: the content always fills the container it was given. + #expect(block.view.constraints.count == 4) + } + + @Test("Failed content is detached") + func failedContentIsDetached() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + let content = try #require(block.page?.view) + block.page?.failLoad() + + #expect(content.superview == nil) + #expect(block.view.intrinsicContentSize.height == 0) + } + + @Test("Empty content is detached") + func emptyContentIsDetached() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + let content = try #require(block.page?.view) + block.page?.send(.empty) + + #expect(content.superview == nil) + } + + /// A reloaded block must not drag the dropped page's view into the new attempt. + @Test("Reload detaches the content of the dropped page") + func reloadDetachesOldContent() throws { + let block = BlockFixture() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + let oldContent = try #require(block.page?.view) + + block.view.reload() + + #expect(oldContent.superview == nil) + } + + // MARK: - Events + + /// There are two public outcomes — shown and not shown; loading is not an outcome, and the + /// host does not hear about it. + @Test("Loading is silent: the delegate hears only outcomes") + func loadingReportsNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + } + + /// A block that never entered a window loads nothing — and has nothing to report. + @Test("Block outside a window reports nothing and loads nothing") + func blockOutsideWindowDoesNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + #expect(block.bed.resolver.resolveCount == 0) + } + + @Test("Shown block reports didLoad") + func shownBlockReportsDidLoad() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(delegate.events == [.loaded]) + } + + @Test("Failed block reports didFail") + func failedBlockReportsDidFail() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// "Empty" is the same not-shown outcome for the host as a failure: it has no event of its own. + @Test("Empty block reports didFail") + func emptyBlockReportsDidFail() async { + let block = BlockFixture(resolution: .empty) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// A host assigning the delegate in `viewDidLoad` would otherwise miss an outcome that already + /// happened. + @Test("Delegate assigned after the outcome still receives it") + func lateDelegateStillReceivesOutcome() async { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + await mainQueueTurn() + + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// A host routinely reassigns the delegate on every reused cell. It must not be handed an + /// outcome already heard: on the outcome it rebuilds the layout, and rebuilding the layout + /// reassigns the delegate again — the block would spin in a loop while scrolling. + @Test("Reassigning the same delegate does not repeat the outcome") + func sameDelegateReassignedHearsTheOutcomeOnce() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.view.delegate = delegate + await mainQueueTurn() + block.view.delegate = delegate + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// A different delegate, however, is a different subscriber, and it must hear an outcome that + /// already happened. + @Test("A delegate replacing another one still receives the outcome") + func replacingDelegateReceivesTheOutcome() async { + let block = BlockFixture() + let first = EmbeddedBlockViewDelegateMock() + block.view.delegate = first + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + let second = EmbeddedBlockViewDelegateMock() + block.view.delegate = second + await mainQueueTurn() + + #expect(first.events == [.failed]) + #expect(second.events == [.failed]) + } + + /// Content may fail again on returning to the window — that is no reason to turn the outcome + /// into a stream of identical events. + @Test("Repeated failure is reported once") + func repeatedFailureIsReportedOnce() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.failLoad() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + @Test("Block that fails after being shown reports both outcomes in order") + func failureAfterLoadReportsBothOutcomes() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.loaded, .failed]) + } + + // MARK: - Presentation for the SwiftUI wrapper + + /// SwiftUI does not read `intrinsicContentSize` on the representable view and draws the host's + /// layers itself, so the wrapper needs both the height and the layer — and what it receives + /// must match what the container actually shows. + @Test("Every change is pushed to the SwiftUI wrapper as a layer and a height") + func presentationChangesArePushedToWrapper() { + let block = BlockFixture() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.send(.ready(height: 96)) + block.page?.send(.empty) + + #expect(reported == [EmbeddedBlockPresentation(layer: .content, height: 120), + EmbeddedBlockPresentation(layer: .nothing, height: 0)]) + } + + /// A failure without an error screen is, for the wrapper, the same collapsed block as an empty + /// one: there is nothing to draw. + @Test("Failed block without an error view reports nothing to show") + func failedBlockReportsNothingToShow() { + let block = BlockFixture() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.failLoad() + + #expect(reported == [EmbeddedBlockPresentation(layer: .nothing, height: 0)]) + } + + /// The container sees the agreement to show an error screen by the assigned `errorView` — and + /// only then asks the wrapper to draw its layer. + @Test("Failed block with an error view reports the error layer") + func failedBlockWithErrorViewReportsErrorLayer() { + let block = BlockFixture() + block.view.errorView = UIView() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.failLoad() + + #expect(reported == [EmbeddedBlockPresentation(layer: .errorView, height: 120)]) + } + + /// A reload returns the block to loading — the wrapper must show the placeholder again. + @Test("Reload reports the placeholder layer again") + func reloadReportsPlaceholderLayer() { + let block = BlockFixture() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.view.reload() + + #expect(reported == [EmbeddedBlockPresentation(layer: .placeholder, height: 120)]) + } + + // MARK: - Lifecycle + + /// The host never starts or stops content by hand: the only trigger is the window. + @Test("Entering and leaving a window starts and stops the content") + func windowMembershipDrivesTheContent() { + let block = BlockFixture() + + #expect(block.bed.resolver.resolveCount == 0) + + block.attachToWindow() + #expect(block.page?.loadCount == 1) + #expect(block.page?.cancelCount == 0) + + block.removeFromWindow() + #expect(block.page?.cancelCount == 1) + } + + /// The block scrolls across the screen in a feed and survives switching tabs: every such pass + /// must not cost a reload, a flash of the shimmer, or repeated events to the host. + @Test("Block returning to the window keeps its content as it was") + func returningBlockKeepsItsContent() async throws { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + let content = try #require(block.page?.view) + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + #expect(block.page?.loadCount == 1) + #expect(content.superview === block.view) + #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.loaded]) + } + + /// A block that failed to show tries again on returning to the window — but the attempt does + /// not reclaim the space the host already took back. Otherwise a collapsed block would jerk + /// the layout to its height and flash the shimmer on every pass across the screen, showing + /// nothing in the end. + @Test("Collapsed block stays collapsed while it tries again") + func collapsedBlockDoesNotReExpandWhileRetrying() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + // The attempt is genuinely new — the page loads again... + #expect(block.page?.loadCount == 2) + // ...but the container does not claim space for it and does not flash the shimmer. + #expect(block.view.intrinsicContentSize.height == 0) + #expect(block.view.subviews.isEmpty) + #expect(delegate.events == [.failed]) + } + + /// Only shown content expands the block — and then the height comes back, and the host hears + /// that the block has finally appeared. + @Test("Retry that succeeds gives the block its height back") + func successfulRetryExpandsTheBlock() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.failed, .loaded]) + } + + /// A reload is the host's explicit consent to a full cycle again, so it claims space: the + /// block shows the placeholder again even if it was collapsed before the reload. + @Test("Reload after a collapse shows the placeholder again") + func reloadAfterCollapseShowsThePlaceholder() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + + block.view.reload() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView }) + } + + /// An empty block collapses the same way — and just as much does not reclaim its space back. + @Test("Empty block stays collapsed when it returns to the window") + func emptyBlockStaysCollapsedOnReturn() async { + let block = BlockFixture(resolution: .empty) + block.attachToWindow() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(block.view.subviews.isEmpty) + } + + // MARK: - Timeout + + /// The container, not the content, guarantees the host's layout will not wait forever: a + /// silent page past its budget collapses and reports a failure. + @Test("Silent block times out, collapses and reports didFail") + func silentBlockTimesOut() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events == [.failed]) + // Content is stopped, so it can no longer revive the expired block. + #expect(block.page?.cancelCount == 1) + } + + @Test("Block shown in time is not failed by the timeout") + func shownBlockIsNotTimedOut() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.page?.send(.ready(height: 96)) + // A shown block has disarmed the budget, so a declared "time is up" no longer concerns it. + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.loaded]) + #expect(block.page?.cancelCount == 0) + } + + /// Leaving the window has already stopped the content — a disarmed timeout must not fail + /// something that is not running. + @Test("Leaving the window disarms the timeout") + func leavingWindowDisarmsTimeout() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.removeFromWindow() + block.expireTimeout() + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// The loading budget counts the user's waiting time, not calendar time: nobody waits for the + /// block in the background, and collapsing it there makes no sense — otherwise the user would + /// come back to a block that gave up without ever being on screen. That the budget then + /// continues from the remainder instead of being granted anew is checked by the tests of + /// `EmbeddedBlockReadyTimeout` itself. + @Test("Timeout pauses in the background and resumes on return") + func timeoutPausesInBackground() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + + block.enterBackground() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events.isEmpty) + + block.enterForeground() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events == [.failed]) + } + + /// A block outside a window loads nothing, so it needs no budget either. + @Test("Returning from the background does not arm a timeout outside a window") + func foregroundOutsideWindowArmsNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.enterForeground() + block.expireTimeout() + await mainQueueTurn() + + // The countdown did not just fail to fire — it was never armed at all. + #expect(block.timeoutBed.scheduler.lastDelay == nil) + #expect(delegate.events.isEmpty) + #expect(block.view.intrinsicContentSize.height == 120) + } + + // MARK: - Reload + + /// A reload takes the same path as the first start: the block returns to loading, and the + /// host hears the new outcome in full, even if it matches the previous one. + @Test("Reload restarts the block and reports the outcome again") + func reloadRestartsTheBlock() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + block.view.reload() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(block.bed.resolver.forceRefreshHistory == [false, true]) + #expect(delegate.events == [.loaded, .loaded]) + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// Content lives only while the block is in a window: there is nothing to reload on an + /// invisible block. + @Test("Reload outside a window does nothing") + func reloadOutsideWindowDoesNothing() { + let block = BlockFixture() + + block.view.reload() + + #expect(block.bed.resolver.resolveCount == 0) + #expect(block.bed.pageFactory.pages.isEmpty) + } + + /// A new attempt also gets a new budget — otherwise a reloaded block would hang loading + /// forever. + @Test("Reload arms the timeout again") + func reloadArmsTheTimeoutAgain() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + block.page?.send(.ready(height: 96)) + + block.view.reload() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events.last == .failed) + } + + // MARK: - Helpers + + /// Outcomes are delivered on the next turn of the main queue, so a block queued after them + /// continues only once they have run — the queue is serial and FIFO. + private func mainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } +} + +/// A block with every dependency substituted and a live window: the window must outlive the test, +/// otherwise the view would fly out of the window mid-check and the content would stop on its own. +@MainActor +private final class BlockFixture { + + let bed: EmbeddedBlockTestBed + + /// The budget is handed to the view from outside, so "time is up" here happens on the test's + /// command rather than through a sleep: `expireTimeout()`. + let timeoutBed: EmbeddedBlockTimeoutBed + + let view: MindboxEmbeddedBlockView + + private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + var page: EmbeddedBlockPageMock? { bed.page } + + init(height: CGFloat = 120, + resolution: EmbeddedBlockResolution = .content(.stub)) { + let bed = EmbeddedBlockTestBed(resolution: resolution) + let timeoutBed = EmbeddedBlockTimeoutBed() + self.bed = bed + self.timeoutBed = timeoutBed + self.view = MindboxEmbeddedBlockView(id: "block-id", + height: height, + contentProvider: bed.provider, + timeout: timeoutBed.timeout) + } + + func attachToWindow() { + window.addSubview(view) + } + + func removeFromWindow() { + view.removeFromSuperview() + } + + /// Declares that the waiting budget has run out. + func expireTimeout() { + timeoutBed.scheduler.fireAll() + } + + func enterBackground() { + timeoutBed.enterBackground() + } + + func enterForeground() { + timeoutBed.enterForeground() + } +} diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 27c57670c..e0f045eda 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -30,4 +30,5 @@ extension Tag { @Tag static var inAppTags: Self @Tag static var userAgent: Self @Tag static var dependencyInjection: Self + @Tag static var embeddedBlocks: Self }