From 02a0f52e2cdf2533cafa65502757418e2397bc65 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:28:26 +0300 Subject: [PATCH 01/23] MOBILE-303: Decode promo actions from the plural promoActions key The API returns promoActions, but the model decoded the singular promoAction key, so decodeIfPresent silently dropped the array ever since the field was added in 2021. Hybrid bridges (React Native) re-encode the decoded model before handing it to JS, so the promo actions never reached the app at all. The explicit encode(to:) keeps the re-encoded JSON on the same wire keys as the decoder; without it the compiler synthesizes encoding from property names, which is exactly what hid this mismatch. The public promoAction property keeps its name to avoid an API break, and productListItems intentionally keeps its historical encode-only key. --- Mindbox/Model/OperationResponse.swift | 30 +++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index ca647a859..e422f7a91 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -42,14 +42,38 @@ open class OperationResponse: OperationResponseType { personalOffers = try container.decodeIfPresent([PersonalOffersResponse].self, forKey: .personalOffers) balances = try container.decodeIfPresent([BalanceResponse].self, forKey: .balances) discountCards = try container.decodeIfPresent([DiscountCardResponse].self, forKey: .discountCards) - promoAction = try container.decodeIfPresent([PromoActionsResponse].self, forKey: .promoAction) + promoAction = try container.decodeIfPresent([PromoActionsResponse].self, forKey: .promoActions) retailOrderStatistics = try container.decodeIfPresent(RetailOrderStatisticsResponse.self, forKey: .retailOrderStatistics) } + // `encode(to:)` must stay in sync with `Keys` — the compiler can't synthesize it + // from this enum (it isn't named `CodingKeys`), and synthesized encoding by + // property names is exactly what hid the promoAction/promoActions mismatch. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + try container.encode(status, forKey: .status) + try container.encodeIfPresent(customer, forKey: .customer) + try container.encodeIfPresent(productList, forKey: .productList) + try container.encodeIfPresent(productListItems, forKey: .productListItems) + try container.encodeIfPresent(recommendations, forKey: .recommendations) + try container.encodeIfPresent(customerSegmentations, forKey: .customerSegmentations) + try container.encodeIfPresent(setProductCountInList, forKey: .setProductCountInList) + try container.encodeIfPresent(promoCode, forKey: .promoCode) + try container.encodeIfPresent(personalOffers, forKey: .personalOffers) + try container.encodeIfPresent(balances, forKey: .balances) + try container.encodeIfPresent(discountCards, forKey: .discountCards) + try container.encodeIfPresent(promoAction, forKey: .promoActions) + try container.encodeIfPresent(retailOrderStatistics, forKey: .retailOrderStatistics) + } + enum Keys: String, CodingKey { case status case customer case productList + // Encode-only: both productList shapes decode from the `productList` wire key, + // but have always re-encoded under their own property names — kept that way + // so the bridge payload only gains promoActions, nothing else moves. + case productListItems case recommendations case customerSegmentations case setProductCountInList @@ -57,7 +81,9 @@ open class OperationResponse: OperationResponseType { case personalOffers case balances case discountCards - case promoAction + // The API sends the plural key; the `promoAction` property keeps its + // historical singular name because renaming it would break the public SDK surface. + case promoActions case retailOrderStatistics } } From 24b00044b8526d1642a2df07473f32d4eda00345 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:28:41 +0300 Subject: [PATCH 02/23] MOBILE-303: Cover the OperationResponse wire-key contract Locks down the promoActions decode/re-encode key, the untouched wire keys, and both productList shapes, so the next contract drift fails a test instead of silently dropping a field. --- Mindbox.xcodeproj/project.pbxproj | 4 + .../Network/OperationResponseTests.swift | 131 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 MindboxTests/Network/OperationResponseTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 6f31cb621..18453a311 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -450,6 +450,7 @@ A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */; }; + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.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 */; }; @@ -1225,6 +1226,7 @@ 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 = ""; }; @@ -2299,6 +2301,7 @@ F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */, + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */, ); path = Network; sourceTree = ""; @@ -4854,6 +4857,7 @@ F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */, + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */, EA395B77BB16CEFE6DC91D1D /* TransparentViewSyncOperationResponseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift new file mode 100644 index 000000000..526ed1f3c --- /dev/null +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -0,0 +1,131 @@ +// +// OperationResponseTests.swift +// MindboxTests +// +// Created by Sergei Semko on 16.07.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@testable import Mindbox + +/// Wire-contract tests for `OperationResponse` (MOBILE-303): the API returns promo +/// actions under the plural `promoActions` key, and the JSON re-encoded for the +/// hybrid bridges (`createJSON`) must use the same wire keys as the decoder — +/// otherwise a field silently vanishes between the API and the JS layer. +@Suite("OperationResponse wire contract", .tags(.decoding, .customOperation)) +struct OperationResponseTests { + + /// Response shaped like the documented `get-promotions-for-customer` payload. + private static let responseJSON = Data(""" + { + "status": "Success", + "promoActions": [ + { + "ids": { "externalId": "summer-sale" }, + "name": "Summer sale", + "description": "10% off everything", + "startDateTimeUtc": "2026-06-01T00:00:00Z", + "endDateTimeUtc": "2026-08-31T23:59:59Z", + "customFields": { "testCustomField": "value" }, + "limits": [ + { + "type": "personalLimit", + "untilDateTimeUtc": "2026-08-31T23:59:59Z", + "amount": { "type": "absolute", "value": 3 }, + "used": { "amount": 1 } + } + ] + }, + { "name": "Second action" } + ], + "promoCode": { "isUsed": false }, + "balances": [ { "total": 100, "available": 90 } ], + "discountCards": [ { "ids": { "number": "1234" } } ] + } + """.utf8) + + private func decodeResponse() throws -> OperationResponse { + try JSONDecoder().decode(OperationResponse.self, from: Self.responseJSON) + } + + private func reEncodedDictionary(of response: OperationResponse) throws -> [String: Any] { + let json = try #require(response.createJSON().data(using: .utf8)) + return try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + } + + @Test("Promo actions are decoded from the plural promoActions wire key") + func decodesPromoActionsFromPluralKey() throws { + let response = try decodeResponse() + + let actions = try #require(response.promoAction, + "the API sends promoActions (plural); the array must not be silently dropped") + try #require(actions.count == 2) + #expect(actions[0].name == "Summer sale") + #expect(actions[0].description == "10% off everything") + #expect(actions[0].ids?["externalId"] == "summer-sale") + #expect(actions[0].startDateTimeUtc != nil) + #expect(actions[0].endDateTimeUtc != nil) + #expect(actions[0].limits?.count == 1) + #expect(actions[1].name == "Second action") + } + + @Test("createJSON re-encodes promo actions under the promoActions wire key") + func createJSONKeepsPluralWireKey() throws { + let response = try decodeResponse() + + let dict = try reEncodedDictionary(of: response) + + #expect(!dict.keys.contains("promoAction"), + "the singular key never existed on the wire and must not leak into re-encoded JSON") + let actions = try #require(dict["promoActions"] as? [[String: Any]]) + try #require(actions.count == 2) + #expect(actions[0]["name"] as? String == "Summer sale") + #expect(actions[1]["name"] as? String == "Second action") + } + + @Test("createJSON keeps every other wire key unchanged") + func createJSONKeepsOtherWireKeys() throws { + let response = try decodeResponse() + + let dict = try reEncodedDictionary(of: response) + + #expect(Set(dict.keys) == ["status", "promoActions", "promoCode", "balances", "discountCards"]) + } + + // The two productList shapes share one wire key on decode, but have always + // re-encoded under their own property names (synthesized behavior). Locked + // down here so the MOBILE-303 encode(to:) rewrite changes nothing but promoActions. + @Test("An array productList re-encodes under the productList key") + func productListArrayKeepsItsKey() throws { + let response = try JSONDecoder().decode( + OperationResponse.self, + from: Data(#"{"status": "Success", "productList": [{"count": 2, "price": 100}]}"#.utf8)) + + try #require(response.productList?.count == 1) + let dict = try reEncodedDictionary(of: response) + #expect((dict["productList"] as? [[String: Any]])?.count == 1) + #expect(!dict.keys.contains("productListItems")) + } + + @Test("An object productList re-encodes under the productListItems key") + func productListObjectKeepsItemsKey() throws { + let response = try JSONDecoder().decode( + OperationResponse.self, + from: Data(#"{"status": "Success", "productList": {"items": [{"priceForCustomer": 5}]}}"#.utf8)) + + try #require(response.productListItems?.items?.count == 1) + let dict = try reEncodedDictionary(of: response) + #expect((dict["productListItems"] as? [String: Any]) != nil) + #expect(!dict.keys.contains("productList")) + } + + @Test("A response without promo actions decodes with a nil field and no error") + func decodesWithoutPromoActions() throws { + let response = try JSONDecoder().decode(OperationResponse.self, + from: Data(#"{"status": "Success"}"#.utf8)) + #expect(response.promoAction == nil) + #expect(response.status == .success) + } +} From 47fa728649dc97fb17c7b6e1c78bca2e5466de0d Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:40:51 +0300 Subject: [PATCH 03/23] MOBILE-303: Mark promoAction for rename to promoActions in 3.0 --- Mindbox/Model/OperationResponse.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index e422f7a91..8b79db7f3 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -21,6 +21,8 @@ open class OperationResponse: OperationResponseType { public let personalOffers: [PersonalOffersResponse]? public let balances: [BalanceResponse]? public let discountCards: [DiscountCardResponse]? + // TODO: MOBILE-303 — rename to `promoActions` in 3.0: the API key is plural, + // the singular property name is a source-breaking legacy we keep until a major release. public let promoAction: [PromoActionsResponse]? public let retailOrderStatistics: RetailOrderStatisticsResponse? From 7e9d191dd2e00f6de6385cc0ed337751a8463d92 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:49:23 +0300 Subject: [PATCH 04/23] MOBILE-303: Mark the productListItems encode-only key for removal in 3.0 --- Mindbox/Model/OperationResponse.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index 8b79db7f3..0a7edfd70 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -75,6 +75,8 @@ open class OperationResponse: OperationResponseType { // Encode-only: both productList shapes decode from the `productList` wire key, // but have always re-encoded under their own property names — kept that way // so the bridge payload only gains promoActions, nothing else moves. + // TODO: MOBILE-303 — drop this key in 3.0 and re-encode both shapes under + // `productList`, making the bridge payload fully wire-faithful (as on Android). case productListItems case recommendations case customerSegmentations From 3461580675b7a2711bf8e476fdc68ed3119fa4d8 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:34:34 +0300 Subject: [PATCH 05/23] MOBILE-303: Cover a production-shaped promo-actions payload Anonymized shape of the sync-operation response from the client report: fractional seconds longer than the .SSS parse pattern (ICU truncates, doesn't shift), sibling non-Utc date keys, and boolean custom fields that must survive re-encoding. --- .../Network/OperationResponseTests.swift | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift index 526ed1f3c..e372192d3 100644 --- a/MindboxTests/Network/OperationResponseTests.swift +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -121,6 +121,85 @@ struct OperationResponseTests { #expect(!dict.keys.contains("productList")) } + /// Shape of a production sync-operation response reported in MOBILE-303, fully + /// anonymized: fractional seconds longer than the `.SSS` parse pattern (6 and 5 + /// digits), sibling non-Utc date keys, `timeZoneMode`, and custom fields mixing + /// booleans with strings. All values are synthetic; the key set and the date + /// string formats are what mirror the real payload. + private static let productionShapedJSON = Data(""" + { + "status": "Success", + "promoActions": [ + { + "ids": { "externalId": "promo-12345678" }, + "description": "First promo description", + "endDateTime": "2026-12-31T21:00:00Z", + "endDateTimeUtc": "2026-12-31T21:00:00Z", + "name": "First promo", + "startDateTime": "2026-01-02T03:04:05.256574Z", + "startDateTimeUtc": "2026-01-02T03:04:05.256574Z", + "timeZoneMode": "project", + "customFields": { + "offerEnabled": true, + "offerPlacement": "top", + "imageUrl": "https://example.com/promo-1.png", + "inAppOperation": "Mobile.SomeOperation", + "modalVariant": "text", + "buttonText": "Accept", + "actionUrl": "https://example.com", + "formId": "1" + } + }, + { + "ids": { "externalId": "promo-87654321" }, + "description": "Second promo description", + "endDateTime": "2026-12-31T21:00:00Z", + "endDateTimeUtc": "2026-12-31T21:00:00Z", + "name": "Second promo", + "startDateTime": "2026-01-02T03:04:06.94785Z", + "startDateTimeUtc": "2026-01-02T03:04:06.94785Z", + "timeZoneMode": "project", + "customFields": { + "offerEnabled": true, + "offerPlacement": "top", + "imageUrl": "https://example.com/promo-2.webp", + "inAppOperation": "Mobile.SomeOperation", + "modalVariant": "image", + "modalImageUrl": "https://example.com/modal-2.jpg", + "buttonText": "Get two", + "actionUrl": "https://example.com", + "formId": "2" + } + } + ] + } + """.utf8) + + @Test("A production-shaped payload decodes and survives re-encoding") + func decodesProductionShapedPayload() throws { + let response = try JSONDecoder().decode(OperationResponse.self, from: Self.productionShapedJSON) + + let actions = try #require(response.promoAction) + try #require(actions.count == 2) + #expect(actions[0].name == "First promo") + #expect(actions[0].ids?["externalId"] == "promo-12345678") + + // ICU truncates fractional seconds beyond 3 digits on parse (.256574 → .256); + // a regression to "N digits = N milliseconds" would shift this by ~4 minutes. + let reference = try #require(ISO8601DateFormatter().date(from: "2026-01-02T03:04:05Z")) + let start = try #require(actions[0].startDateTimeUtc).date + #expect(abs(start.timeIntervalSince(reference)) < 1.0) + + let dict = try reEncodedDictionary(of: response) + let encodedActions = try #require(dict["promoActions"] as? [[String: Any]]) + try #require(encodedActions.count == 2) + // CustomFields must round-trip JSON types untouched — booleans stay booleans. + let customFields = try #require(encodedActions[0]["customFields"] as? [String: Any]) + #expect(customFields["offerEnabled"] as? Bool == true) + #expect(customFields["offerPlacement"] as? String == "top") + #expect(customFields["formId"] as? String == "1") + } + @Test("A response without promo actions decodes with a nil field and no error") func decodesWithoutPromoActions() throws { let response = try JSONDecoder().decode(OperationResponse.self, From f207d74467c94c64aeca67c39650b4fbc3a60464 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 23 Jul 2026 14:43:59 +0300 Subject: [PATCH 06/23] MOBILE-258: Allow path prefix for operaionsDomain --- .../OperationsDomainConfigPolicy.swift | 2 +- Mindbox/MBConfiguration.swift | 7 +- Mindbox/Network/Helpers/HostNormalizer.swift | 3 +- .../Network/Helpers/URLRequestBuilder.swift | 4 +- Mindbox/Validators/URLValidator.swift | 18 ++++ .../Configuration/MBConfigurationTests.swift | 49 ++++++++++ .../Network/OperationsURLRoutingTests.swift | 93 +++++++++++++++++++ 7 files changed, 170 insertions(+), 6 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 960ad9b93..a5587a393 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -32,7 +32,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { + guard URLValidator.isValidHostWithOptionalPath(HostNormalizer.extractHost(value)) else { return .rejected(value) } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index ed73d63dd..cfcdd8681 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -27,8 +27,9 @@ public struct MBConfiguration: Codable { /// /// - Parameter endpoint: Used for app identification /// - Parameter domain: Used for generating baseurl for REST - /// - Parameter operationsDomain: Optional host for sending operations. Overridden by - /// the value from the mobile JSON config when present. Default `nil` (use `domain`). + /// - Parameter operationsDomain: Optional host for sending operations, optionally with + /// a path prefix (e.g. `domain.com/api/v2`) — operation endpoints are appended after it. + /// Overridden by the value from the mobile JSON config when present. Default `nil` (use `domain`). /// - Parameter previousInstallationId: Used to create tracking continuity by uuid /// - Parameter previousDeviceUUID: Used instead of the generated value /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. @@ -63,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard URLValidator.isValidHost(HostNormalizer.extractHost(operationsDomain)) else { + guard URLValidator.isValidHostWithOptionalPath(HostNormalizer.extractHost(operationsDomain)) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)")) Logger.error(error.asLoggerError()) throw error diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index 1dc2320b7..9ef6f8e43 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -9,7 +9,8 @@ import Foundation /// Scheme-aware normalization for `domain` / `operationsDomain` inputs. -/// Accepts `host`, `https://host`, `http://host`, with or without trailing slash. +/// Accepts `host`, `https://host`, `http://host`, optionally followed by a path +/// prefix (`host/api/v2`), with or without trailing slash. The path is preserved. enum HostNormalizer { private static let httpsPrefix = "https://" diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 60ba12625..455ee6bdb 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -48,7 +48,9 @@ struct URLRequestBuilder { throw URLError(.badURL) } - components.path = route.path + // The base may carry a path prefix (anonymizer case: `host/api/mindbox-regular`); + // operation endpoints are appended after it, not instead of it. + components.path += route.path components.queryItems = makeQueryItems(for: route.queryParameters) return components diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index eb973f5c1..61e3e9e45 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -20,6 +20,24 @@ enum URLValidator { /// RFC 1035: each label 1..63 chars. private static let maxLabelLength = 63 + /// Path-prefix rule for `isValidHostWithOptionalPath`: zero or more non-empty + /// segments of unreserved URL characters. Query, fragment and empty segments + /// do not match. + private static let pathPrefixPattern = "^(?:/[A-Za-z0-9._~%-]+)*$" + + /// Validates `host` or `host/path-prefix` (scheme must already be stripped, + /// e.g. via `HostNormalizer.extractHost`). Used for `operationsDomain`, which + /// may carry a path prefix (e.g. `domain.com/api/v2`) — operation endpoints + /// are appended after it. Path segments must be non-empty; query and fragment + /// are rejected. + static func isValidHostWithOptionalPath(_ value: String) -> Bool { + guard let slashIndex = value.firstIndex(of: "/") else { return isValidHost(value) } + let host = String(value[.. Bool { guard !host.isEmpty, host.count <= maxHostLength else { return false } diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index dc3425ccf..0eb341089 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -135,6 +135,55 @@ struct MBConfigurationTests { } } + @Test("operationsDomain accepts path prefix (MOBILE-258)") + func operationsDomainAcceptsPathPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular" + ) + #expect(config.operationsDomain == "https://api-v2.letu.ru/api/mindbox-regular") + } + + @Test("operationsDomain accepts bare host with path prefix") + func operationsDomainAcceptsBareHostWithPathPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/api/v2" + ) + #expect(config.operationsDomain == "domain.com/api/v2") + } + + @Test("operationsDomain with query string throws") + func operationsDomainWithQueryThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/api?x=1" + ) + } + } + + @Test("operationsDomain with empty path segment throws") + func operationsDomainWithEmptyPathSegmentThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com//api" + ) + } + } + + @Test("domain with path prefix still throws — domain stays host-only") + func domainWithPathPrefixThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "api.mindbox.ru/api/v2") + } + } + // MARK: - Init: previousInstallationId / previousDeviceUUID UUID handling @Test("Valid previousInstallationId UUID is stored") diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 230b9a85a..9118782ab 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -164,6 +164,57 @@ struct OperationsURLRoutingTests { #expect(url?.path == "/v3/operations/async") } + // MARK: - Path prefix in operationsDomain (MOBILE-258) + + @Test("operationsDomain with path prefix appends operation endpoint after the prefix") + func pathPrefixAppendsEndpointAfterPrefix() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api-v2.letu.ru") + #expect(url?.path == "/api/mindbox-regular/v3/operations/async") + } + + @Test("Sync operations route keeps the path prefix") + func pathPrefixKeptOnSyncRoute() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) + + let url = try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url + #expect(url?.path == "/api/mindbox-regular/v3/operations/sync") + } + + @Test("Bare host with path prefix gets default https:// and keeps the prefix") + func barePathPrefixUsesHttps() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "domain.com/api/v2") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "domain.com") + #expect(url?.path == "/api/v2/v3/operations/async") + } + + @Test("Trailing slash after path prefix does not produce a double slash") + func trailingSlashAfterPathPrefixStripped() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "domain.com/api/v2/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/api/v2/v3/operations/async") + } + + @Test("Config and geo routes are unaffected by operations path prefix") + func domainRoutesUnaffectedByPathPrefix() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + + let geoURL = try builder.asURLRequest(route: FetchInAppGeoRoute()).url + #expect(geoURL?.host == domain) + #expect(geoURL?.path == "/geo") + } + @Test("Fails fast when base URL is unparseable (no silent relative-URL request)") func failsFastOnUnparseableBaseURL() { // Embedded space defeats both `URLComponents(string:)` parsing and @@ -364,6 +415,48 @@ struct OperationsURLRoutingTests { ) } + @Test("Policy — saves value with path prefix in canonical form (MOBILE-258)") + func policySavesPathPrefixValue() { + #expect( + OperationsDomainConfigPolicy.action(for: "api-v2.letu.ru/api/mindbox-regular", currentlyStored: nil) + == .save("https://api-v2.letu.ru/api/mindbox-regular") + ) + } + + @Test("Policy — normalizes trailing slash after path prefix") + func policyNormalizesTrailingSlashAfterPathPrefix() { + #expect( + OperationsDomainConfigPolicy.action(for: "https://x.ru/api/v2/", currentlyStored: nil) + == .save("https://x.ru/api/v2") + ) + } + + @Test("Policy — keeps when canonical path-prefix form equals stored") + func policyKeepsOnMatchingPathPrefix() { + #expect( + OperationsDomainConfigPolicy.action( + for: "x.ru/api/v2/", + currentlyStored: "https://x.ru/api/v2" + ) == .keep + ) + } + + @Test("Policy — rejects path with query string") + func policyRejectsPathWithQuery() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/api?x=1", currentlyStored: "https://good.ru") + == .rejected("x.ru/api?x=1") + ) + } + + @Test("Policy — rejects empty path segment") + func policyRejectsEmptyPathSegment() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru//api", currentlyStored: "https://good.ru") + == .rejected("x.ru//api") + ) + } + // MARK: - Persistence lifecycle @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") From e7b73123e749071d0b04cc8b8590008cc5cc7329 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 24 Jul 2026 10:45:22 +0300 Subject: [PATCH 07/23] MOBILE-258: Fix regex for operationDomain path --- Mindbox/Validators/URLValidator.swift | 9 +++-- .../Configuration/MBConfigurationTests.swift | 34 +++++++++++++++++++ .../Network/OperationsURLRoutingTests.swift | 26 ++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index 61e3e9e45..ef5ef1689 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -21,9 +21,12 @@ enum URLValidator { private static let maxLabelLength = 63 /// Path-prefix rule for `isValidHostWithOptionalPath`: zero or more non-empty - /// segments of unreserved URL characters. Query, fragment and empty segments - /// do not match. - private static let pathPrefixPattern = "^(?:/[A-Za-z0-9._~%-]+)*$" + /// segments made of unreserved URL characters or complete `%XX` percent-encoded + /// octets. Query, fragment, empty segments, and a bare/incomplete `%` (not + /// followed by two hex digits) do not match — an incomplete escape parses fine + /// here but corrupts (or fails) `URLComponents` at request time, so it must be + /// rejected at validation, not discovered later as a runtime `badURL`. + private static let pathPrefixPattern = "^(?:/(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+)*$" /// Validates `host` or `host/path-prefix` (scheme must already be stripped, /// e.g. via `HostNormalizer.extractHost`). Used for `operationsDomain`, which diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index 0eb341089..4ff3b780b 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -177,6 +177,40 @@ struct MBConfigurationTests { } } + @Test("operationsDomain accepts valid percent-encoded octet in path") + func operationsDomainAcceptsValidPercentEncoding() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%20b" + ) + #expect(config.operationsDomain == "domain.com/a%20b") + } + + @Test("operationsDomain with non-hex percent escape throws") + func operationsDomainWithNonHexPercentEscapeThrows() { + // Regression: "%zz" parsed fine at validation but corrupted (or failed) + // URLComponents at request time — must be rejected here instead. + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%zz" + ) + } + } + + @Test("operationsDomain with dangling percent at end of path throws") + func operationsDomainWithDanglingPercentThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%" + ) + } + } + @Test("domain with path prefix still throws — domain stays host-only") func domainWithPathPrefixThrows() { #expect(throws: MindboxError.self) { diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 9118782ab..5156a17f2 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -457,6 +457,32 @@ struct OperationsURLRoutingTests { ) } + @Test("Policy — accepts valid percent-encoded octet in path") + func policyAcceptsValidPercentEncoding() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%20b", currentlyStored: nil) + == .save("https://x.ru/a%20b") + ) + } + + @Test("Policy — rejects non-hex percent escape from config") + func policyRejectsNonHexPercentEscape() { + // Regression: "%zz" is not a valid percent-encoded octet — it would corrupt + // (or fail to build) the request URL at runtime if accepted here. + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%zz", currentlyStored: "https://good.ru") + == .rejected("x.ru/a%zz") + ) + } + + @Test("Policy — rejects dangling percent at end of path from config") + func policyRejectsDanglingPercent() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%", currentlyStored: "https://good.ru") + == .rejected("x.ru/a%") + ) + } + // MARK: - Persistence lifecycle @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") From 33ef78349b083fa2ab0689826141b4eabd1ae695 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:09:05 +0300 Subject: [PATCH 08/23] MOBILE-312: Heal poisoned in-app WebView cache --- Mindbox.xcodeproj/project.pbxproj | 16 ++ .../WebView/Bridge/MindboxWebBridge.swift | 24 +++ .../WebView/Debug/MindboxWebViewFacade.swift | 39 ++++ .../Views/WebView/InAppWebViewDataStore.swift | 41 ++++ .../Views/WebView/InAppWebViewFactory.swift | 7 + .../Views/WebView/InAppWebViewHTTPError.swift | 24 +++ .../Prewarm/InAppWebViewPrewarmService.swift | 82 +++++++- .../Views/WebView/TransparentView.swift | 44 ++++ .../Views/WebView/WebViewController.swift | 19 +- .../WebView/WebViewNoCacheRetryPolicy.swift | 38 ++++ Mindbox/Utilities/Constants.swift | 25 +++ .../WebView/InAppWebViewHTTPErrorTests.swift | 59 ++++++ .../WebViewNoCacheRetryPolicyTests.swift | 120 +++++++++++ .../WebView/WebViewTimeoutErrorTests.swift | 21 ++ .../InAppWebViewPrewarmHealTests.swift | 194 ++++++++++++++++++ 15 files changed, 746 insertions(+), 7 deletions(-) create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewHTTPError.swift create mode 100644 Mindbox/InAppMessages/Presentation/Views/WebView/WebViewNoCacheRetryPolicy.swift create mode 100644 MindboxTests/InApp/Tests/WebView/InAppWebViewHTTPErrorTests.swift create mode 100644 MindboxTests/InApp/Tests/WebView/WebViewNoCacheRetryPolicyTests.swift create mode 100644 MindboxTests/InApp/Tests/WebViewPrewarmTests/InAppWebViewPrewarmHealTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 6f31cb621..0b8ca3567 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -18,7 +18,11 @@ 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 */; }; @@ -765,9 +769,13 @@ 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 = ""; }; @@ -1555,6 +1563,8 @@ 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */, 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */, 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */, + 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */, + 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */, 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */, 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */, 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */, @@ -3090,6 +3100,8 @@ FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */, A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */, 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */, + 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */, + 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */, F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */, 0E8BCE24EE9540D6BD7F655D /* Prewarm */, 47B1B6A92F6174E0000A4B67 /* LocalState */, @@ -4351,6 +4363,8 @@ 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */, B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */, 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */, + 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */, + 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */, C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */, 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */, 47EFBB2F2CB92B240023A4B9 /* SegmentationCheckResponse.swift in Sources */, @@ -4736,6 +4750,8 @@ 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */, B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */, CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */, + 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */, + 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */, F349753A2DEE2CB700BEC667 /* ShownInAppsDictionaryMigrationTests.swift in Sources */, 4766A8AE2C9325B0002D15A4 /* ABTestsConfigParsingTests.swift in Sources */, F3FEEA9F2C25AF39000E9D0F /* StubContainer.swift in Sources */, diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/MindboxWebBridge.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/MindboxWebBridge.swift index be807357d..5b34b3a69 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/MindboxWebBridge.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/MindboxWebBridge.swift @@ -22,6 +22,12 @@ public protocol WebBridgeNavigationDelegate: AnyObject { func webBridge(_ bridge: MindboxWebBridge, didFinishNavigation url: URL?) func webBridge(_ bridge: MindboxWebBridge, didFailProvisionalNavigation url: URL?, error: Error) func webBridge(_ bridge: MindboxWebBridge, decidePolicyFor url: URL?, navigationType: WKNavigationType, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) + func webBridge(_ bridge: MindboxWebBridge, didReceiveHTTPError url: String?) +} + +@_spi(Internal) +public extension WebBridgeNavigationDelegate { + func webBridge(_ bridge: MindboxWebBridge, didReceiveHTTPError url: String?) {} } protocol BridgePendingStore: AnyObject { @@ -62,6 +68,10 @@ public final class MindboxWebBridge: NSObject { // Idempotent: a reused WebView may still carry a previous show's handler of this name. controller.removeScriptMessageHandler(forName: Constants.WebViewBridgeJS.handlerName) controller.add(self, name: Constants.WebViewBridgeJS.handlerName) + // Take over the HTTP-error detection channel too: on a borrowed instance the + // prewarm's monitor still owns it — from here the errors belong to this show. + controller.removeScriptMessageHandler(forName: Constants.WebViewHTTPErrorJS.handlerName) + controller.add(self, name: Constants.WebViewHTTPErrorJS.handlerName) webView.navigationDelegate = self } @@ -172,6 +182,20 @@ public final class MindboxWebBridge: NSObject { extension MindboxWebBridge: WKScriptMessageHandler { public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == Constants.WebViewHTTPErrorJS.handlerName { + // Same staleness gate as bridge messages below: a leftover page's error on a + // reused WebView must not consume this show's one-shot retry. The show's own + // subresources only start loading after its document commits, so nothing real + // is lost. + guard expectedNavigationCommitted else { + logStaleNavigation("http error message") + return + } + guard let failedURL = InAppWebViewHTTPError.failedResourceURL(from: message.body) else { return } + navigationDelegate?.webBridge(self, didReceiveHTTPError: failedURL) + return + } + guard message.name == Constants.WebViewBridgeJS.handlerName else { Logger.common( message: "[WebView] Bridge: received message with wrong handler name: \(message.name)", diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift index 2df623328..14dbc136a 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift @@ -50,6 +50,15 @@ public protocol InappWebViewFacadeProtocol: AnyObject { func evaluateJavaScript(_ script: String, completion: @escaping (Result) -> Void) func setBridgeMessageDelegate(_ delegate: WebBridgeMessageDelegate?) func setNavigationDelegate(_ delegate: WebBridgeNavigationDelegate?) + func retryContentLoadBypassingCache(failedURL: String?, onPurgeOutcome: @escaping (_ didRemoveAnything: Bool) -> Void) + func releaseRetainedContent() +} + +@_spi(Internal) +public extension InappWebViewFacadeProtocol { + // Defaults so existing conformers (mocks, test apps) keep compiling. + func retryContentLoadBypassingCache(failedURL: String?, onPurgeOutcome: @escaping (_ didRemoveAnything: Bool) -> Void) {} + func releaseRetainedContent() {} } @_spi(Internal) @@ -89,6 +98,13 @@ public final class MindboxWebViewFacade: MindboxInternalWebViewFacadeProtocol { private var fetchTask: URLSessionDataTask? private var isClosed = false + // Main-confined. The content page is retained so a poisoned-cache HTTP error can be + // answered by reloading the exact same page after the purge (mirror of Android's + // `lastLoadedContent`). Released on `init` — after it the page has proven it can boot + // and a reload would tear down a live in-app. + private var retainedContentHTML: String? + private var retainedContentBaseURL: URL? + public init(params: [String: JSONValue]?, operation: (name: String, body: String)? = nil, userAgent: String, @@ -145,11 +161,34 @@ public final class MindboxWebViewFacade: MindboxInternalWebViewFacadeProtocol { onFailure() return } + self.retainedContentHTML = html + self.retainedContentBaseURL = url self.bridge.expectContentNavigation(self.webView.loadHTMLString(html, baseURL: url)) } } } + public func retryContentLoadBypassingCache(failedURL: String?, onPurgeOutcome: @escaping (_ didRemoveAnything: Bool) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self, !self.isClosed, let html = self.retainedContentHTML else { return } + let baseURL = self.retainedContentBaseURL + InAppWebViewDataStore.purgeCache(forHostOf: failedURL) { [weak self] didRemoveAnything in + onPurgeOutcome(didRemoveAnything) + // The reload must be sequenced strictly after the purge completes — + // re-fetching before the poisoned entry is gone would just replay it. + guard let self, !self.isClosed else { return } + self.bridge.expectContentNavigation(self.webView.loadHTMLString(html, baseURL: baseURL)) + } + } + } + + public func releaseRetainedContent() { + DispatchQueue.main.async { [weak self] in + self?.retainedContentHTML = nil + self?.retainedContentBaseURL = nil + } + } + public func reloadWebView() { DispatchQueue.main.async { [weak self] in guard let self, !self.isClosed else { return } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewDataStore.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewDataStore.swift index 7fa6df754..118c97138 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewDataStore.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewDataStore.swift @@ -7,6 +7,7 @@ // import WebKit +import MindboxLogger /// The single `WKWebsiteDataStore` used by every Mindbox WebView (prewarm and shows alike). /// @@ -60,4 +61,44 @@ enum InAppWebViewDataStore { guard isCacheFeatureEnabled else { return WKWebsiteDataStore.nonPersistent() } return instance } + + static func purgeCache(forHostOf urlString: String?, completion: @escaping (_ didRemoveAnything: Bool) -> Void) { + let cacheTypes: Set = [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache] + let store = shared() + // Trimmed the same way the recoverability predicate trims, so a padded URL that + // passed it still resolves to a host here. + let trimmedURLString = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) + let host = trimmedURLString.flatMap(URL.init(string:))?.host?.lowercased() + let isIsolatedStore: Bool = { + if #available(iOS 17.0, *) { return isCacheFeatureEnabled } + return false + }() + // Callers reload a WKWebView from this completion; WebKit calls these handlers on + // the main thread today, but the docs don't promise it. + let finish: (Bool) -> Void = { didRemoveAnything in + if Thread.isMainThread { + completion(didRemoveAnything) + } else { + DispatchQueue.main.async { completion(didRemoveAnything) } + } + } + store.fetchDataRecords(ofTypes: cacheTypes) { records in + let matching = records.filter { record in + guard let host else { return false } + let domain = record.displayName.lowercased() + return host == domain || host.hasSuffix("." + domain) + } + Logger.common(message: "[WebView] Cache purge for host \(host ?? "nil"): \(matching.isEmpty ? "no matching records" : "removing \(matching.count) record(s)")", + level: .debug, category: .webViewInAppMessages) + if !matching.isEmpty { + store.removeData(ofTypes: cacheTypes, for: matching) { finish(true) } + } else if isIsolatedStore { + // Fallback full wipe of the isolated store: still reported as "removed + // nothing" — the host's record was not found, which is the race signal. + store.removeData(ofTypes: cacheTypes, modifiedSince: .distantPast) { finish(false) } + } else { + finish(false) + } + } + } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewFactory.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewFactory.swift index 07618c789..cf18ae18b 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewFactory.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewFactory.swift @@ -19,6 +19,13 @@ enum InAppWebViewFactory { config.applicationNameForUserAgent = userAgent config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] + config.userContentController.addUserScript( + WKUserScript( + source: Constants.WebViewHTTPErrorJS.detectionScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ) let webView = WKWebView(frame: .zero, configuration: config) #if DEBUG if #available(iOS 16.4, *) { diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewHTTPError.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewHTTPError.swift new file mode 100644 index 000000000..66093475d --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/InAppWebViewHTTPError.swift @@ -0,0 +1,24 @@ +import Foundation + +enum InAppWebViewHTTPError { + + static let loadFailureDescription = "load failure (no HTTP status on WebKit)" + + static func isScriptResourceURL(_ url: String?) -> Bool { + let raw = url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !raw.isEmpty else { return false } + let beforeFragment = raw.components(separatedBy: "#")[0] + let path = beforeFragment.components(separatedBy: "?")[0] + return path.lowercased().hasSuffix(".js") + } + + static func isRecoverable(url: String?) -> Bool { + isScriptResourceURL(url) + } + + static func failedResourceURL(from body: Any) -> String? { + guard let dict = body as? [String: Any], + dict["type"] as? String == "httpError" else { return nil } + return dict["url"] as? String + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmService.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmService.swift index 22267346b..0c6972bef 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmService.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmService.swift @@ -62,16 +62,37 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { // Retained here because navigationDelegate is weak; armed until the first borrow. private let prewarmNavigationPolicy = InAppWebViewPrewarmNavigationPolicy() + private var lastPrewarmContentPage: (html: String, baseURL: URL)? + // Heal attempt bookkeeping — same scheme as WebViewNoCacheRetryPolicy (spec D2): + // a purge that provably removed nothing keeps the heal armed for one more attempt + // (WebKit's write-behind cache persistence races the first purge), hard-capped. + private var healAttemptsUsed = 0 + private var healPurgeRemovedEntry = false + private var isHealPurgeInFlight = false + private static let maxHealAttempts = WebViewNoCacheRetryPolicy.maxAttempts + private let isCacheEnabled: () -> Bool + // Contract: must call `completion` on the main thread — the reload it sequences + // navigates the webview. The default (`InAppWebViewDataStore.purgeCache`) guarantees it. + private let purgeCache: (_ failedURL: String?, _ completion: @escaping (_ didRemoveAnything: Bool) -> Void) -> Void + private let httpErrorMonitor = PrewarmHTTPErrorMonitor() + init(persistenceStorage: PersistenceStorage, makeWebView: @escaping () -> WKWebView = { InAppWebViewFactory.make() }, fetchHTML: @escaping (URL, @escaping (String?) -> Void) -> Void = InAppWebViewPrewarmService.defaultFetchHTML, - loadCachedConfig: @escaping () -> ConfigResponse? = InAppWebViewPrewarmService.defaultLoadCachedConfig) { + loadCachedConfig: @escaping () -> ConfigResponse? = InAppWebViewPrewarmService.defaultLoadCachedConfig, + isCacheEnabled: @escaping () -> Bool = { InAppWebViewDataStore.isCacheFeatureEnabled }, + purgeCache: @escaping (_ failedURL: String?, _ completion: @escaping (_ didRemoveAnything: Bool) -> Void) -> Void = InAppWebViewDataStore.purgeCache) { self.persistenceStorage = persistenceStorage self.learnedHostsStore = InAppWebViewLearnedHostsStore(persistenceStorage: persistenceStorage) self.makeWebView = makeWebView self.fetchHTML = fetchHTML self.loadCachedConfig = loadCachedConfig + self.isCacheEnabled = isCacheEnabled + self.purgeCache = purgeCache startMemoryWarningObserver() + httpErrorMonitor.onHTTPError = { [weak self] url in + self?.healPrewarmContentPage(failedURL: url) + } } deinit { @@ -94,6 +115,7 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { guard let self, let webView = self.warmWebView, !self.isLentToShow else { return } webView.stopLoading() self.warmWebView = nil + self.lastPrewarmContentPage = nil Logger.common(message: "[WebView] Prewarm: memory warning — releasing the parked warm instance", level: .info, category: .webViewInAppMessages) } @@ -150,6 +172,9 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { // Latch even when there is nothing to hand out: a show is starting, and a // resource prewarm kicked off after this point would compete with it. hasBeenBorrowed = true + // The prewarm's healing days are over (the show runs its own retry policy); + // don't keep the page HTML reachable for the rest of the process. + lastPrewarmContentPage = nil guard let webView = warmWebView else { return nil } // Presentation is serialized upstream, but that flag has known races — never let // a second show steal the instance out of an on-screen in-app. @@ -192,6 +217,7 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { webView.configuration.userContentController.removeAllScriptMessageHandlers() } else { webView.configuration.userContentController.removeScriptMessageHandler(forName: Constants.WebViewBridgeJS.handlerName) + webView.configuration.userContentController.removeScriptMessageHandler(forName: Constants.WebViewHTTPErrorJS.handlerName) } webView.loadHTMLString(Self.blankPage, baseURL: nil) } @@ -227,6 +253,7 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { if self.warmWebView == nil { self.warmWebView = self.makeWebView() self.warmWebView?.navigationDelegate = self.prewarmNavigationPolicy + self.installHTTPErrorMonitor(on: self.warmWebView) } if !hosts.isEmpty { self.prewarmNavigationPolicy.allow(baseURL) @@ -257,11 +284,51 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { from: baseURL, endpoint: endpoint, deviceUUID: deviceUUID ) prewarmNavigationPolicy.allow(prewarmBaseURL) + lastPrewarmContentPage = isCacheEnabled() ? (html, prewarmBaseURL) : nil + healAttemptsUsed = 0 + healPurgeRemovedEntry = false + isHealPurgeInFlight = false warmWebView.loadHTMLString(html, baseURL: prewarmBaseURL) Logger.common(message: "[WebView] Prewarm: content page under \(prewarmBaseURL.absoluteString), endpoint \(endpoint)", level: .info, category: .webViewInAppMessages) } + private func installHTTPErrorMonitor(on webView: WKWebView?) { + guard let controller = webView?.configuration.userContentController else { return } + // Idempotent remove-then-add, same as the bridge: never crash on a leftover handler. + controller.removeScriptMessageHandler(forName: Constants.WebViewHTTPErrorJS.handlerName) + controller.add(httpErrorMonitor, name: Constants.WebViewHTTPErrorJS.handlerName) + } + + func healPrewarmContentPage(failedURL: String?) { + // Observation log for every incoming report (mirrors the show path): heal + // attempts are capped, so later reports would otherwise vanish without a trace. + Logger.common(message: "[WebView] Prewarm: subresource error — \(InAppWebViewHTTPError.loadFailureDescription) for \(failedURL ?? "nil")", + level: .debug, category: .webViewInAppMessages) + guard InAppWebViewHTTPError.isRecoverable(url: failedURL) else { return } + guard !hasBeenBorrowed, !healPurgeRemovedEntry, !isHealPurgeInFlight, + healAttemptsUsed < Self.maxHealAttempts, + warmWebView != nil, let page = lastPrewarmContentPage else { return } + healAttemptsUsed += 1 + isHealPurgeInFlight = true + Logger.common(message: "[WebView] Prewarm: \(InAppWebViewHTTPError.loadFailureDescription) for script \(failedURL ?? "nil")" + + " — purging its host's cache and reloading the content page (attempt \(healAttemptsUsed)/\(Self.maxHealAttempts))", + level: .info, category: .webViewInAppMessages) + purgeCache(failedURL) { [weak self] didRemoveAnything in + guard let self else { return } + self.isHealPurgeInFlight = false + // A purge that found the host's record latches the heal: the reload it + // sequences is the only one that can help. An empty purge is the + // write-behind race signal — the next error report may try once more. + if didRemoveAnything { self.healPurgeRemovedEntry = true } + // Re-check after the async purge: a show may have borrowed the instance (or a + // release dropped it) while the purge was in flight — the prewarm must never + // navigate a webview it no longer owns. + guard !self.hasBeenBorrowed, let warmWebView = self.warmWebView else { return } + warmWebView.loadHTMLString(page.html, baseURL: page.baseURL) + } + } + private enum ReleaseReason: String { case featureToggleOff = "the prewarm feature toggle is off" case noWebviewInApps = "no webview in-apps in config" @@ -275,6 +342,7 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { // Latch before the guard: even with nothing to drop yet, a stage-1 hop that // lands after this release must not start a prewarm the config just killed. self.resourcePrewarmLatched = true + self.lastPrewarmContentPage = nil guard !self.hasBeenBorrowed, let webView = self.warmWebView else { return } webView.stopLoading() self.warmWebView = nil @@ -305,3 +373,15 @@ final class InAppWebViewPrewarmService: InAppWebViewPrewarmServiceProtocol { InAppConfigurationRepository().fetchDecodedConfigFromCache() } } + +private final class PrewarmHTTPErrorMonitor: NSObject, WKScriptMessageHandler { + + var onHTTPError: ((_ url: String?) -> Void)? + + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + guard message.name == Constants.WebViewHTTPErrorJS.handlerName, + let failedURL = InAppWebViewHTTPError.failedResourceURL(from: message.body) else { return } + onHTTPError?(failedURL) + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index d0e11f029..15cb3be65 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -33,6 +33,9 @@ final class TransparentView: UIView { /// closing authority — it must close the show itself. private var hasReceivedInit = false private var hasCapturedObservedHosts = false + private let noCacheRetryPolicy = WebViewNoCacheRetryPolicy { + InAppWebViewDataStore.isCacheFeatureEnabled + } private lazy var localStateStorage: WebViewLocalStateStorageProtocol = DI.injectOrFail(WebViewLocalStateStorageProtocol.self) private lazy var permissionHandlerRegistry = DI.injectOrFail(PermissionHandlerRegistryProtocol.self) private lazy var hapticService: HapticServiceProtocol = DI.injectOrFail(HapticServiceProtocol.self) @@ -140,6 +143,12 @@ final class TransparentView: UIView { setupTimeoutTimer() } + var noCacheRetryTelemetryDetail: String? { + noCacheRetryPolicy.lastHTTPErrorDetail.map { detail in + "Last script HTTP error: \(detail); no-cache retry attempted: \(noCacheRetryPolicy.hasRetried)." + } + } + private func setupTimeoutTimer() { quizInitTimeoutWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -205,6 +214,9 @@ extension TransparentView: WebBridgeMessageDelegate { case .`init`: hasReceivedInit = true quizInitTimeoutWorkItem?.cancel() + // The page has proven it can boot — drop the retained retry content (mirror of + // Android's handleInitAction cleanup; the policy's one-shot state stays). + facade?.releaseRetainedContent() hapticService.prepare() webViewAction?.onInit() case .click: @@ -309,6 +321,38 @@ extension TransparentView: WebBridgeNavigationDelegate { }) } + func webBridge(_ bridge: MindboxWebBridge, didReceiveHTTPError url: String?) { + let isRecoverable = InAppWebViewHTTPError.isRecoverable(url: url) + Logger.common( + message: "[WebView] Subresource error: \(InAppWebViewHTTPError.loadFailureDescription) for \(url ?? "nil")", + level: isRecoverable ? .default : .debug, + category: .webViewInAppMessages + ) + guard noCacheRetryPolicy.onHTTPError(url: url, hasReceivedInit: hasReceivedInit) else { return } + retryContentPageBypassingCache(failedURL: url) + } + + private func retryContentPageBypassingCache(failedURL: String?) { + Logger.common( + message: "[WebView] Retrying In-App content load with cache bypassed (\(noCacheRetryPolicy.lastHTTPErrorDetail ?? "unknown"))", + level: .info, + category: .webViewInAppMessages + ) + readyChecker?.cancel() + readyChecker = nil + restartTimeoutTimer() + facade?.retryContentLoadBypassingCache(failedURL: failedURL) { [weak self] didRemoveAnything in + self?.noCacheRetryPolicy.notePurgeOutcome(didRemoveAnything: didRemoveAnything) + if !didRemoveAnything { + Logger.common( + message: "[WebView] Cache purge found no entry for the failed script (write-behind race?) — one more retry may follow", + level: .debug, + category: .webViewInAppMessages + ) + } + } + } + func webBridge(_ bridge: MindboxWebBridge, didFailProvisionalNavigation url: URL?, error: any Error) { Logger.common(message: "[WebView] WKNavigationDelegate: Loading error \(error.localizedDescription)", category: .webViewInAppMessages) delegate?.closeLoadFailedWebViewVC( diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift index 882f90e71..586f0aac6 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift @@ -222,17 +222,24 @@ extension WebViewController: WebVCDelegate { func closeTimeoutWebViewVC() { Logger.common(message: "[WebView] WebViewVC closeTimeoutOrErrorWebViewVC", category: .webViewInAppMessages) reportErrorAndClose( - Self.timeoutError(readyCheckGaveUp: transparentWebView?.readyCheckDidGiveUp == true, inAppId: id) + Self.timeoutError( + readyCheckGaveUp: transparentWebView?.readyCheckDidGiveUp == true, + inAppId: id, + httpErrorDetail: transparentWebView?.noCacheRetryTelemetryDetail + ) ) } /// The init timeout is the single closing authority, but monitoring must still tell /// "the page loaded and its JS bridge never appeared" (a content defect) apart from - /// "the page never finished loading" (a load defect). - static func timeoutError(readyCheckGaveUp: Bool, inAppId: String) -> InAppPresentationError { - readyCheckGaveUp - ? .webviewPresentationFailed("[WebView] JS bridge missing after page load (init timeout) for in-app id \(inAppId).") - : .webviewLoadFailed("[WebView] WebView initialization timeout for in-app id \(inAppId).") + /// "the page never finished loading" (a load defect). `httpErrorDetail` adds the + /// concrete cause when a script subresource answered with an HTTP error — with it a + /// poisoned-cache death names the resource and status instead of a generic timeout. + static func timeoutError(readyCheckGaveUp: Bool, inAppId: String, httpErrorDetail: String? = nil) -> InAppPresentationError { + let suffix = httpErrorDetail.map { " \($0)" } ?? "" + return readyCheckGaveUp + ? .webviewPresentationFailed("[WebView] JS bridge missing after page load (init timeout) for in-app id \(inAppId).\(suffix)") + : .webviewLoadFailed("[WebView] WebView initialization timeout for in-app id \(inAppId).\(suffix)") } func closeLoadFailedWebViewVC(reason: String) { diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewNoCacheRetryPolicy.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewNoCacheRetryPolicy.swift new file mode 100644 index 000000000..185db79da --- /dev/null +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewNoCacheRetryPolicy.swift @@ -0,0 +1,38 @@ +import Foundation + +final class WebViewNoCacheRetryPolicy { + + static let maxAttempts = 2 + + private let isCacheFeatureEnabled: () -> Bool + + private(set) var lastHTTPErrorDetail: String? + + private(set) var attemptsUsed = 0 + + private var purgeRemovedEntry = false + + private var isPurgeOutcomePending = false + + var hasRetried: Bool { attemptsUsed > 0 } + + init(isCacheFeatureEnabled: @escaping () -> Bool) { + self.isCacheFeatureEnabled = isCacheFeatureEnabled + } + + func onHTTPError(url: String?, hasReceivedInit: Bool) -> Bool { + guard InAppWebViewHTTPError.isRecoverable(url: url) else { return false } + lastHTTPErrorDetail = "\(InAppWebViewHTTPError.loadFailureDescription) for \(url ?? "nil")" + guard !hasReceivedInit else { return false } + guard !purgeRemovedEntry, !isPurgeOutcomePending, attemptsUsed < Self.maxAttempts else { return false } + guard isCacheFeatureEnabled() else { return false } + attemptsUsed += 1 + isPurgeOutcomePending = true + return true + } + + func notePurgeOutcome(didRemoveAnything: Bool) { + isPurgeOutcomePending = false + if didRemoveAnything { purgeRemovedEntry = true } + } +} diff --git a/Mindbox/Utilities/Constants.swift b/Mindbox/Utilities/Constants.swift index baf99b177..a33b88335 100644 --- a/Mindbox/Utilities/Constants.swift +++ b/Mindbox/Utilities/Constants.swift @@ -60,6 +60,31 @@ enum Constants { static let bridgeFunctionReadyCheck = "(() => typeof window.bridgeMessagesHandlers !== 'undefined' && typeof window.bridgeMessagesHandlers.emit === 'function')()" } + enum WebViewHTTPErrorJS { + static let handlerName = "SdkHttpErrorMonitor" + + static let detectionScript = """ + (function () { + if (window.__mbxHttpErrorMonitorInstalled) { return; } + window.__mbxHttpErrorMonitorInstalled = true; + var reported = {}; + window.addEventListener('error', function (e) { + var target = e && e.target; + if (!target || !target.tagName || !(target.src || target.href)) { return; } + try { + var url = String(target.src || target.href || ''); + if (!url || reported[url]) { return; } + reported[url] = true; + window.webkit.messageHandlers.\(handlerName).postMessage({ + type: 'httpError', + url: url + }); + } catch (_) {} + }, true); + })(); + """ + } + /// Constants used for migration management. enum Migration { diff --git a/MindboxTests/InApp/Tests/WebView/InAppWebViewHTTPErrorTests.swift b/MindboxTests/InApp/Tests/WebView/InAppWebViewHTTPErrorTests.swift new file mode 100644 index 000000000..05da382ce --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/InAppWebViewHTTPErrorTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import Mindbox + +@Suite("InApp WebView HTTP error predicate", .tags(.webView)) +struct InAppWebViewHTTPErrorTests { + + @Test + func scriptURLsMatch() { + #expect(InAppWebViewHTTPError.isScriptResourceURL("https://api.example.com/scripts/v1/tracker.js")) + #expect(InAppWebViewHTTPError.isScriptResourceURL("https://api.example.com/scripts/v1/tracker.js?v=1.0.31")) + #expect(InAppWebViewHTTPError.isScriptResourceURL("https://web-static.mindbox.ru/js/byendpoint/x.webview.js?_=5949609")) + #expect(InAppWebViewHTTPError.isScriptResourceURL("https://cdn.test/main.js#fragment")) + #expect(InAppWebViewHTTPError.isScriptResourceURL("https://cdn.test/MAIN.JS")) + #expect(InAppWebViewHTTPError.isScriptResourceURL(" https://cdn.test/padded.js ")) + } + + @Test + func nonScriptURLsDoNotMatch() { + #expect(!InAppWebViewHTTPError.isScriptResourceURL(nil)) + #expect(!InAppWebViewHTTPError.isScriptResourceURL("")) + #expect(!InAppWebViewHTTPError.isScriptResourceURL(" ")) + #expect(!InAppWebViewHTTPError.isScriptResourceURL("https://cdn.test/banner.png")) + #expect(!InAppWebViewHTTPError.isScriptResourceURL("https://fonts.googleapis.com/css2?family=Inter")) + #expect(!InAppWebViewHTTPError.isScriptResourceURL("https://stats.test/client-stats?pg=1")) + // ".js" only in the query/fragment, not in the path + #expect(!InAppWebViewHTTPError.isScriptResourceURL("https://cdn.test/page?file=tracker.js")) + #expect(!InAppWebViewHTTPError.isScriptResourceURL("https://cdn.test/page#tracker.js")) + // path merely containing ".js" without ending on it + #expect(!InAppWebViewHTTPError.isScriptResourceURL("https://cdn.test/tracker.json")) + } + + + @Test + func recoverableMeansAFailedScript() { + #expect(InAppWebViewHTTPError.isRecoverable(url: "https://cdn.test/main.js")) + #expect(!InAppWebViewHTTPError.isRecoverable(url: "https://cdn.test/banner.png")) + #expect(!InAppWebViewHTTPError.isRecoverable(url: nil)) + } + + @Test + func parsesDetectionScriptMessage() { + let url = InAppWebViewHTTPError.failedResourceURL(from: [ + "type": "httpError", + "url": "https://cdn.test/main.js" + ] as [String: Any]) + #expect(url == "https://cdn.test/main.js") + } + + @Test + func rejectsForeignMessages() { + #expect(InAppWebViewHTTPError.failedResourceURL(from: ["type": "somethingElse", "url": "x"] as [String: Any]) == nil) + #expect(InAppWebViewHTTPError.failedResourceURL(from: "not a dictionary") == nil) + #expect(InAppWebViewHTTPError.failedResourceURL(from: ["url": "x"] as [String: Any]) == nil) + // An httpError message without a usable URL is dropped at the parsing layer. + #expect(InAppWebViewHTTPError.failedResourceURL(from: ["type": "httpError"] as [String: Any]) == nil) + #expect(InAppWebViewHTTPError.failedResourceURL(from: ["type": "httpError", "url": NSNull()] as [String: Any]) == nil) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/WebViewNoCacheRetryPolicyTests.swift b/MindboxTests/InApp/Tests/WebView/WebViewNoCacheRetryPolicyTests.swift new file mode 100644 index 000000000..ada1c01c5 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebView/WebViewNoCacheRetryPolicyTests.swift @@ -0,0 +1,120 @@ +// +// WebViewNoCacheRetryPolicyTests.swift +// MindboxTests +// +// Created by sozinov on 24.07.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +@Suite("WebView no-cache retry policy", .tags(.webView)) +struct WebViewNoCacheRetryPolicyTests { + + private let trackerURL = "https://api.example.com/scripts/v1/tracker.js?v=1.0.31" + + private func policy(cacheEnabled: Bool = true) -> WebViewNoCacheRetryPolicy { + WebViewNoCacheRetryPolicy { cacheEnabled } + } + + @Test + func grantsARetryForAFailedScriptBeforeInit() { + let policy = policy() + + #expect(policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + #expect(policy.hasRetried) + #expect(policy.lastHTTPErrorDetail == "load failure (no HTTP status on WebKit) for \(trackerURL)") + } + + @Test + func successfulPurgeLatchesTheRetryForTheShowSession() { + let policy = policy() + + #expect(policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + policy.notePurgeOutcome(didRemoveAnything: true) + // The purge provably removed the poisoned entry — a later error means the poison + // is upstream, another reload would just replay the failure. + #expect(!policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + // The later error still refreshes the telemetry detail. + #expect(!policy.onHTTPError(url: "https://cdn.test/other.js", hasReceivedInit: false)) + #expect(policy.lastHTTPErrorDetail == "load failure (no HTTP status on WebKit) for https://cdn.test/other.js") + } + + /// Write-behind race (spec D2a): on the very first poisoning the entry may not be + /// visible to the purge yet. An empty purge keeps the retry armed for one more attempt. + @Test + func emptyPurgeAllowsExactlyOneMoreAttempt() { + let policy = policy() + + #expect(policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + policy.notePurgeOutcome(didRemoveAnything: false) + #expect(policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + policy.notePurgeOutcome(didRemoveAnything: false) + // Hard cap: two attempts per show session whatever the purge outcomes were — + // repeated empty purges mean the poison is not in the client cache (offline/CDN). + #expect(!policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + #expect(policy.attemptsUsed == WebViewNoCacheRetryPolicy.maxAttempts) + } + + /// A second error report (another failing script, or a duplicate message posted by + /// page JS) must not double-fire the retry while the first purge has not reported back. + @Test + func noSecondGrantWhileThePurgeOutcomeIsPending() { + let policy = policy() + + #expect(policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + #expect(!policy.onHTTPError(url: "https://cdn.test/other.js", hasReceivedInit: false)) + policy.notePurgeOutcome(didRemoveAnything: false) + #expect(policy.onHTTPError(url: "https://cdn.test/other.js", hasReceivedInit: false)) + } + + @Test + func doesNotRetryAfterTheRuntimeInitialized() { + let policy = policy() + + #expect(!policy.onHTTPError(url: trackerURL, hasReceivedInit: true)) + #expect(!policy.hasRetried) + // A live in-app must not be reloaded, but the error is still worth remembering. + #expect(policy.lastHTTPErrorDetail == "load failure (no HTTP status on WebKit) for \(trackerURL)") + } + + @Test + func doesNotRetryNonScriptResources() { + let policy = policy() + + #expect(!policy.onHTTPError(url: "https://cdn.test/banner.png", hasReceivedInit: false)) + #expect(!policy.onHTTPError( + url: "https://personalization-speedtest.g.mindbox.ru/client-stats?x=1", + hasReceivedInit: false + )) + #expect(policy.lastHTTPErrorDetail == nil) + } + + @Test + func cacheFeatureOffBlocksTheRetryButKeepsTheTelemetryDetail() { + let policy = policy(cacheEnabled: false) + + #expect(!policy.onHTTPError(url: trackerURL, hasReceivedInit: false)) + #expect(!policy.hasRetried) + #expect(policy.lastHTTPErrorDetail == "load failure (no HTTP status on WebKit) for \(trackerURL)") + } + + @Test + func cacheGateIsConsultedOnlyWhenARetryWouldActuallyFire() { + var consulted = 0 + let policy = WebViewNoCacheRetryPolicy { + consulted += 1 + return true + } + + _ = policy.onHTTPError(url: "https://cdn.test/banner.png", hasReceivedInit: false) + #expect(consulted == 0) + + _ = policy.onHTTPError(url: trackerURL, hasReceivedInit: true) + #expect(consulted == 0) + + _ = policy.onHTTPError(url: trackerURL, hasReceivedInit: false) + #expect(consulted == 1) + } +} diff --git a/MindboxTests/InApp/Tests/WebView/WebViewTimeoutErrorTests.swift b/MindboxTests/InApp/Tests/WebView/WebViewTimeoutErrorTests.swift index c423c16fd..e3c00fec5 100644 --- a/MindboxTests/InApp/Tests/WebView/WebViewTimeoutErrorTests.swift +++ b/MindboxTests/InApp/Tests/WebView/WebViewTimeoutErrorTests.swift @@ -25,4 +25,25 @@ struct WebViewTimeoutErrorTests { return } } + + @Test + func timeoutCarriesTheScriptHTTPErrorDetail() throws { + let detail = "Last script HTTP error: HTTP 404 for https://cdn.test/tracker.js; no-cache retry attempted: true." + + guard case .webviewLoadFailed(let description) = WebViewController.timeoutError( + readyCheckGaveUp: false, inAppId: "x", httpErrorDetail: detail + ) else { + Issue.record("expected webviewLoadFailed") + return + } + #expect(description.hasSuffix(" \(detail)")) + + guard case .webviewLoadFailed(let plain) = WebViewController.timeoutError( + readyCheckGaveUp: false, inAppId: "x", httpErrorDetail: nil + ) else { + Issue.record("expected webviewLoadFailed") + return + } + #expect(!plain.contains("Last script HTTP error")) + } } diff --git a/MindboxTests/InApp/Tests/WebViewPrewarmTests/InAppWebViewPrewarmHealTests.swift b/MindboxTests/InApp/Tests/WebViewPrewarmTests/InAppWebViewPrewarmHealTests.swift new file mode 100644 index 000000000..f79bb5793 --- /dev/null +++ b/MindboxTests/InApp/Tests/WebViewPrewarmTests/InAppWebViewPrewarmHealTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +import WebKit +@testable import Mindbox + +@MainActor +@Suite("InApp WebView prewarm poisoned-cache heal", .tags(.webView)) +struct InAppWebViewPrewarmHealTests { + + private final class SpyWebView: WKWebView { + private(set) var loadedHTMLCount = 0 + + override func loadHTMLString(_ string: String, baseURL: URL?) -> WKNavigation? { + loadedHTMLCount += 1 + return nil // spy only — keep WebKit from actually navigating in unit tests + } + + override func stopLoading() {} + } + + private final class PurgeSpy { + private(set) var purgedURLs: [String?] = [] + var pendingCompletions: [(Bool) -> Void] = [] + var completesImmediately = true + var didRemoveAnythingResult = true + + func purge(_ failedURL: String?, completion: @escaping (Bool) -> Void) { + purgedURLs.append(failedURL) + if completesImmediately { + completion(didRemoveAnythingResult) + } else { + pendingCompletions.append(completion) + } + } + } + + private let spy: SpyWebView + private let purgeSpy: PurgeSpy + private let service: InAppWebViewPrewarmService + + init() throws { + self = try Self.init(cacheEnabled: true) + } + + private init(cacheEnabled: Bool) throws { + let spy = SpyWebView(frame: .zero, configuration: WKWebViewConfiguration()) + self.spy = spy + let purgeSpy = PurgeSpy() + self.purgeSpy = purgeSpy + + let storage = MockPersistenceStorage() + storage.configuration = try MBConfiguration(endpoint: "Test.Endpoint", domain: "api.mindbox.ru") + storage.deviceUUID = "test-device-uuid" + + service = InAppWebViewPrewarmService( + persistenceStorage: storage, + makeWebView: { spy }, + fetchHTML: { _, completion in completion("content") }, + loadCachedConfig: { nil }, + isCacheEnabled: { cacheEnabled }, + purgeCache: { url, completion in purgeSpy.purge(url, completion: completion) } + ) + } + + private func drainMainQueue() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { continuation.resume() } + } + } + + private func runPrewarm() async throws { + service.prewarmResources(for: try loadPrewarmTestConfig("InAppWebviewValid")) + await drainMainQueue() + await drainMainQueue() + #expect(spy.loadedHTMLCount == 2) // preconnect + content page + } + + private var poisonedScript: String { "https://web-static.mindbox.ru/js/byendpoint/x.webview.js" } + + @Test("A recoverable script error purges the host and reloads the content page exactly once") + func healsOncePerContentLoad() async throws { + try await runPrewarm() + + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 1) + #expect(purgeSpy.purgedURLs.first == poisonedScript) + #expect(spy.loadedHTMLCount == 3) + + // One-shot: the same (or another) error must not loop the reload. + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 1) + #expect(spy.loadedHTMLCount == 3) + } + + @Test("The reload is sequenced strictly after the purge completes") + func reloadWaitsForThePurge() async throws { + try await runPrewarm() + purgeSpy.completesImmediately = false + + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(spy.loadedHTMLCount == 2) // purge still in flight — no reload yet + + purgeSpy.pendingCompletions.forEach { $0(true) } + #expect(spy.loadedHTMLCount == 3) + } + + @Test("An empty purge grants a second heal; the cap stops the third") + func emptyPurgeGrantsASecondHealAttempt() async throws { + try await runPrewarm() + purgeSpy.didRemoveAnythingResult = false + + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 1) + #expect(spy.loadedHTMLCount == 3) + + // The reloaded page hits the (now persisted) poison and reports again. + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 2) + #expect(spy.loadedHTMLCount == 4) + + // Hard cap: two attempts per content load, whatever the purge outcomes were. + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 2) + #expect(spy.loadedHTMLCount == 4) + } + + @Test("A purge that removed the entry latches the heal even below the attempt cap") + func successfulPurgeLatchesTheHeal() async throws { + try await runPrewarm() + purgeSpy.didRemoveAnythingResult = true + + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 1) + + // The entry was provably removed — a repeated error means the poison is upstream. + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 1) + #expect(spy.loadedHTMLCount == 3) + } + + @Test("No second heal while the purge is in flight") + func noSecondHealWhileThePurgeIsInFlight() async throws { + try await runPrewarm() + purgeSpy.completesImmediately = false + + service.healPrewarmContentPage(failedURL: poisonedScript) + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 1) + + // The empty purge reports back — the next error may use the second attempt. + purgeSpy.pendingCompletions.forEach { $0(false) } + purgeSpy.pendingCompletions.removeAll() + #expect(spy.loadedHTMLCount == 3) + + service.healPrewarmContentPage(failedURL: poisonedScript) + #expect(purgeSpy.purgedURLs.count == 2) + } + + @Test("Non-recoverable errors do not trigger the heal") + func ignoresNonRecoverableErrors() async throws { + try await runPrewarm() + + service.healPrewarmContentPage(failedURL: "https://cdn.test/banner.png") + + #expect(purgeSpy.purgedURLs.isEmpty) + #expect(spy.loadedHTMLCount == 2) + } + + @Test("Cache feature off: the page is not retained and the heal is disarmed") + func cacheOffDisarmsTheHeal() async throws { + let suite = try Self.init(cacheEnabled: false) + suite.service.prewarmResources(for: try loadPrewarmTestConfig("InAppWebviewValid")) + await suite.drainMainQueue() + await suite.drainMainQueue() + #expect(suite.spy.loadedHTMLCount == 2) + + suite.service.healPrewarmContentPage(failedURL: poisonedScript) + + #expect(suite.purgeSpy.purgedURLs.isEmpty) + #expect(suite.spy.loadedHTMLCount == 2) + } + + @Test("After a borrow the prewarm never heals — the show runs its own retry policy") + func borrowDisarmsTheHeal() async throws { + try await runPrewarm() + + _ = service.borrowWarmWebView() + service.healPrewarmContentPage(failedURL: poisonedScript) + + #expect(purgeSpy.purgedURLs.isEmpty) + // borrow itself parks the page with one blank load; the heal must add nothing. + #expect(spy.loadedHTMLCount == 3) + } +} From 91b151e4f5cdb8634f4beea94ac9ce986027e50b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:54:36 +0000 Subject: [PATCH 09/23] Update all dependencies (#744) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/distribute-reusable.yml | 2 +- .github/workflows/gitleaks-secrets-validate.yml | 2 +- .github/workflows/linter_and_unit_tests.yml | 8 ++++---- .../workflows/manual-prepare_release_branch.yml | 6 +++--- .github/workflows/prepare_release_branch.yml | 6 +++--- .github/workflows/publish-reusable.yml | 16 ++++++++-------- .github/workflows/release-version-check.yml | 4 ++-- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index 300c689bf..21d251ae8 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} fetch-depth: 3 diff --git a/.github/workflows/gitleaks-secrets-validate.yml b/.github/workflows/gitleaks-secrets-validate.yml index 31312bc20..b262f2219 100644 --- a/.github/workflows/gitleaks-secrets-validate.yml +++ b/.github/workflows/gitleaks-secrets-validate.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 diff --git a/.github/workflows/linter_and_unit_tests.yml b/.github/workflows/linter_and_unit_tests.yml index 35404015c..b005e05a3 100644 --- a/.github/workflows/linter_and_unit_tests.yml +++ b/.github/workflows/linter_and_unit_tests.yml @@ -18,9 +18,9 @@ jobs: SwiftLint: runs-on: ubuntu-latest container: - image: ghcr.io/realm/swiftlint:0.63.3@sha256:b70b763c949399cc3e4fdcfbc7588944d9503d498b6d96e853dfa07a944256e6 + image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - run: swiftlint --config .swiftlint.yml --reporter github-actions-logging --strict build: @@ -31,7 +31,7 @@ jobs: checks: write pull-requests: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: latest-stable @@ -54,7 +54,7 @@ jobs: # survive regardless of the console formatter. Only failures are listed # (include_passed: false), so the PR comment stays compact even on green runs. - name: Publish unit-test summary - uses: mikepenz/action-junit-report@3a81627bfac62268172037048872e8ebd4207e6d # v6.4.1 + uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2 if: ${{ !cancelled() }} with: report_paths: test_output/report.junit diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index bb3f2a581..26d3b01e5 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -40,7 +40,7 @@ jobs: needs: validate-input steps: - name: Checkout minimal repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 @@ -74,7 +74,7 @@ jobs: release_branch: ${{ steps.bump.outputs.release_branch }} steps: - name: Checkout source branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 @@ -108,7 +108,7 @@ jobs: needs: bump_and_branch steps: - name: Checkout the release branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.bump_and_branch.outputs.release_branch }} fetch-depth: 0 diff --git a/.github/workflows/prepare_release_branch.yml b/.github/workflows/prepare_release_branch.yml index ebb90db96..3d67ef147 100644 --- a/.github/workflows/prepare_release_branch.yml +++ b/.github/workflows/prepare_release_branch.yml @@ -39,7 +39,7 @@ jobs: version2: ${{ steps.bump.outputs.version2 }} steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Bump version env: @@ -59,7 +59,7 @@ jobs: needs: bump_version steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 @@ -83,7 +83,7 @@ jobs: needs: check_sdk_version steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Create Pull Request env: diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index 02d4ce435..a2990a203 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -19,7 +19,7 @@ jobs: unit-tests: runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 @@ -44,7 +44,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - name: Extract SDK version @@ -69,7 +69,7 @@ jobs: needs: [set-tag] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -94,7 +94,7 @@ jobs: needs: [delay] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -115,7 +115,7 @@ jobs: needs: [check-podspecs-with-retry] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -142,7 +142,7 @@ jobs: needs: [second-delay] runs-on: macos-26 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -164,7 +164,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.branch }} - name: Release generation @@ -181,7 +181,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} steps: - name: Checkout develop branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: develop - name: Create Pull Request diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index 22f51b42c..945847da6 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -32,13 +32,13 @@ jobs: if: ${{ github.base_ref == 'master' && startsWith(github.head_ref, 'release/') }} steps: - name: Checkout master branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: master path: master - name: Checkout release branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.head_ref }} path: release From 78caa64afc8673eae2db88acfd7fb5ba7e51bf3a Mon Sep 17 00:00:00 2001 From: Anka Date: Mon, 20 Jul 2026 17:19:13 +0000 Subject: [PATCH 10/23] Bump SDK version from 2.15.1 to 2.15.2 --- Mindbox.podspec | 4 ++-- MindboxLogger.podspec | 2 +- MindboxNotifications.podspec | 2 +- SDKVersionProvider/SDKVersionConfig.xcconfig | 2 +- SDKVersionProvider/SDKVersionProvider.swift | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Mindbox.podspec b/Mindbox.podspec index 2868119b0..dead59569 100644 --- a/Mindbox.podspec +++ b/Mindbox.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "Mindbox" - spec.version = "2.15.1" + spec.version = "2.15.2" spec.summary = "SDK for integration with Mindbox" spec.description = "This library allows you to integrate data transfer to Mindbox Marketing Cloud" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" @@ -14,6 +14,6 @@ Pod::Spec.new do |spec| 'Mindbox' => ['Mindbox/**/*.xcassets', 'Mindbox/**/*.xcdatamodeld', 'Mindbox/**/*.xcprivacy'] } spec.swift_version = "5" - spec.dependency 'MindboxLogger', '2.15.1' + spec.dependency 'MindboxLogger', '2.15.2' end diff --git a/MindboxLogger.podspec b/MindboxLogger.podspec index 593198c3a..af7bcd82c 100644 --- a/MindboxLogger.podspec +++ b/MindboxLogger.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxLogger" - spec.version = "2.15.1" + spec.version = "2.15.2" spec.summary = "SDK for utilities to work with Mindbox" spec.description = "-" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" diff --git a/MindboxNotifications.podspec b/MindboxNotifications.podspec index a3e68cef2..546412def 100644 --- a/MindboxNotifications.podspec +++ b/MindboxNotifications.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxNotifications" - spec.version = "2.15.1" + spec.version = "2.15.2" spec.summary = "SDK for integration notifications with Mindbox" spec.description = "This library allows you to integrate notifications and transfer them to Mindbox Marketing Cloud" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" diff --git a/SDKVersionProvider/SDKVersionConfig.xcconfig b/SDKVersionProvider/SDKVersionConfig.xcconfig index c491fb202..d166653e1 100644 --- a/SDKVersionProvider/SDKVersionConfig.xcconfig +++ b/SDKVersionProvider/SDKVersionConfig.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 2.15.1 +MARKETING_VERSION = 2.15.2 diff --git a/SDKVersionProvider/SDKVersionProvider.swift b/SDKVersionProvider/SDKVersionProvider.swift index 618f478a1..7f33b151d 100644 --- a/SDKVersionProvider/SDKVersionProvider.swift +++ b/SDKVersionProvider/SDKVersionProvider.swift @@ -8,6 +8,6 @@ import Foundation public class SDKVersionProvider { - public static let sdkVersion = "2.15.1" + public static let sdkVersion = "2.15.2" } From cc3477a3e5a7df9c572c1a06008a5a62e3e1fcf0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:15:27 +0000 Subject: [PATCH 11/23] Update actions/checkout action to v7 --- .github/workflows/distribute-reusable.yml | 2 +- .github/workflows/gitleaks-secrets-validate.yml | 2 +- .github/workflows/linter_and_unit_tests.yml | 4 ++-- .../workflows/manual-prepare_release_branch.yml | 6 +++--- .github/workflows/prepare_release_branch.yml | 6 +++--- .github/workflows/publish-reusable.yml | 16 ++++++++-------- .github/workflows/release-version-check.yml | 4 ++-- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index 21d251ae8..fa249b703 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} fetch-depth: 3 diff --git a/.github/workflows/gitleaks-secrets-validate.yml b/.github/workflows/gitleaks-secrets-validate.yml index b262f2219..8f412cc48 100644 --- a/.github/workflows/gitleaks-secrets-validate.yml +++ b/.github/workflows/gitleaks-secrets-validate.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/linter_and_unit_tests.yml b/.github/workflows/linter_and_unit_tests.yml index b005e05a3..fd1a481bc 100644 --- a/.github/workflows/linter_and_unit_tests.yml +++ b/.github/workflows/linter_and_unit_tests.yml @@ -20,7 +20,7 @@ jobs: container: image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: swiftlint --config .swiftlint.yml --reporter github-actions-logging --strict build: @@ -31,7 +31,7 @@ jobs: checks: write pull-requests: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: latest-stable diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index 26d3b01e5..9c83cdddc 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -40,7 +40,7 @@ jobs: needs: validate-input steps: - name: Checkout minimal repo - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -74,7 +74,7 @@ jobs: release_branch: ${{ steps.bump.outputs.release_branch }} steps: - name: Checkout source branch - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 @@ -108,7 +108,7 @@ jobs: needs: bump_and_branch steps: - name: Checkout the release branch - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.bump_and_branch.outputs.release_branch }} fetch-depth: 0 diff --git a/.github/workflows/prepare_release_branch.yml b/.github/workflows/prepare_release_branch.yml index 3d67ef147..8e10be19c 100644 --- a/.github/workflows/prepare_release_branch.yml +++ b/.github/workflows/prepare_release_branch.yml @@ -39,7 +39,7 @@ jobs: version2: ${{ steps.bump.outputs.version2 }} steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Bump version env: @@ -59,7 +59,7 @@ jobs: needs: bump_version steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -83,7 +83,7 @@ jobs: needs: check_sdk_version steps: - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Create Pull Request env: diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index a2990a203..ea12731a4 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -19,7 +19,7 @@ jobs: unit-tests: runs-on: macos-26 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 @@ -44,7 +44,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Extract SDK version @@ -69,7 +69,7 @@ jobs: needs: [set-tag] runs-on: macos-26 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -94,7 +94,7 @@ jobs: needs: [delay] runs-on: macos-26 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -115,7 +115,7 @@ jobs: needs: [check-podspecs-with-retry] runs-on: macos-26 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -142,7 +142,7 @@ jobs: needs: [second-delay] runs-on: macos-26 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Update bundler @@ -164,7 +164,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch }} - name: Release generation @@ -181,7 +181,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} steps: - name: Checkout develop branch - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: develop - name: Create Pull Request diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index 945847da6..333ebf538 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -32,13 +32,13 @@ jobs: if: ${{ github.base_ref == 'master' && startsWith(github.head_ref, 'release/') }} steps: - name: Checkout master branch - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: master path: master - name: Checkout release branch - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.head_ref }} path: release From 43da7f665a9bb7fc2fdde455a38fe2de0db24125 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:19:35 +0300 Subject: [PATCH 12/23] MOBILE-340 Match monitoring log requests by MD5 target instead of deviceUUID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review flagged raw deviceUUIDs in the mobile config: every device could read every other device's UUID. The config now carries monitoring.logs[].target — MD5 of the lowercased deviceUUID — and the SDK hashes its own UUID and compares case-insensitively. Contract and test vectors mirror android-sdk #749 (MOBILE-281). iOS 12 has no CryptoKit, so MD5Hash falls back to CC_MD5 behind a wrapper marked deprecated(13.0): warning-free today, and it starts warning at the call site once the deployment target reaches iOS 13 — a cue to delete it. --- Mindbox.xcodeproj/project.pbxproj | 16 ++++++ .../Models/Config/MonitoringModel.swift | 2 +- Mindbox/MindboxLogger/MD5Hash.swift | 49 +++++++++++++++++++ Mindbox/MindboxLogger/SDKLogsManager.swift | 6 ++- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 Mindbox/MindboxLogger/MD5Hash.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 8a1eac8dc..369cf72c4 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -179,6 +179,8 @@ 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 */; }; @@ -383,10 +385,12 @@ 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, ); }; }; @@ -927,6 +931,8 @@ 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 = ""; }; @@ -1131,6 +1137,7 @@ 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 = ""; }; @@ -1151,6 +1158,7 @@ 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; }; @@ -2021,6 +2029,8 @@ isa = PBXGroup; children = ( 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */, + EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */, + 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */, 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */, 4731A7BC2F447C3100CBE1E5 /* MonitoringLogsError.json */, 4731A7BD2F447C3100CBE1E5 /* MonitoringLogsOneElementError.json */, @@ -2847,6 +2857,7 @@ children = ( A154E332299E10F400F8F074 /* Mocks */, A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */, + 2A183433F63D47F684846B0F /* MD5HashTests.swift */, ); path = MindboxLogger; sourceTree = ""; @@ -3025,6 +3036,7 @@ children = ( A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */, A154E382299E5B7500F8F074 /* SDKLogsManager.swift */, + 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */, ); path = MindboxLogger; sourceTree = ""; @@ -4223,6 +4235,8 @@ 4731A81A2F447C3100CBE1E5 /* ConfigMonitoringError.json in Resources */, 4731A81B2F447C3100CBE1E5 /* ConfigSettingsTypeError.json in Resources */, 4731A81C2F447C3100CBE1E5 /* MonitoringConfig.json in Resources */, + AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */, + 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */, 4731A81D2F447C3100CBE1E5 /* InAppFrequencyError.json in Resources */, 4731A81E2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json in Resources */, 4731A81F2F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json in Resources */, @@ -4594,6 +4608,7 @@ B3F4F97C268EEA950092EC3C /* StatusResponse.swift in Sources */, 9B24FAAE28C74BA500F10B5D /* InAppCoreManager.swift in Sources */, A15D701A29AF8142007131E7 /* SDKLogsManager.swift in Sources */, + 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */, 334F3AF6264C199900A6AC00 /* ProductListRequest.swift in Sources */, F36128492BA3193E000382D9 /* PresentationClickTracker.swift in Sources */, 47BD5BFE2C578FB400F965C0 /* BaseMigration.swift in Sources */, @@ -4781,6 +4796,7 @@ F367301D2B7B8B6A00DD0039 /* NotificationFormatTests.swift in Sources */, F35E0C4F2DF0535E00E8A768 /* InAppTrackingServiceTests.swift in Sources */, A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */, + 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */, 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */, D2F7E2482BADB9EF00B24BB8 /* UserVisitManagerTests.swift in Sources */, BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */, diff --git a/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift b/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift index 6b619a130..d7ed39172 100644 --- a/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift +++ b/Mindbox/InAppMessages/Models/Config/MonitoringModel.swift @@ -13,7 +13,7 @@ struct Monitoring: Decodable, Equatable { struct Logs: Decodable, Equatable { let requestId: String - let deviceUUID: String + let target: String let from: String let to: String } diff --git a/Mindbox/MindboxLogger/MD5Hash.swift b/Mindbox/MindboxLogger/MD5Hash.swift new file mode 100644 index 000000000..89c5a877d --- /dev/null +++ b/Mindbox/MindboxLogger/MD5Hash.swift @@ -0,0 +1,49 @@ +// +// MD5Hash.swift +// Mindbox +// +// Created by Sergei Semko on 8/3/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import CommonCrypto +import CryptoKit +import Foundation + +/// Match token for monitoring log requests (MOBILE-340): MD5 hex of the lowercased +/// deviceUUID. Both the hash input and the hex are lowercased, so comparison is +/// case-insensitive. Must stay in sync with the server contract and Android's +/// `Md5Hash` (android-sdk #749). +struct MD5Hash: Equatable { + let hex: String + + init(hex: String) { + self.hex = hex.lowercased() + } + + init(deviceUUID: String) { + self.hex = Self.md5HexDigest(of: deviceUUID.lowercased()) + } + + private static func md5HexDigest(of string: String) -> String { + let data = Data(string.utf8) + let digest: [UInt8] + if #available(iOS 13.0, *) { + digest = Array(Insecure.MD5.hash(data: data)) + } else { + digest = commonCryptoMD5(data) + } + return digest.map { String(format: "%02x", $0) }.joined() + } + + // Internal, not private: no iOS 12 simulator exists on modern Xcode, so tests + // call this fallback directly — CC_MD5 is the same C API on every OS version. + @available(iOS, introduced: 12.0, deprecated: 13.0, message: "CC_MD5 fallback; delete when the deployment target reaches iOS 13") + static func commonCryptoMD5(_ data: Data) -> [UInt8] { + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + data.withUnsafeBytes { bytes in + _ = CC_MD5(bytes.baseAddress, CC_LONG(data.count), &digest) + } + return digest + } +} diff --git a/Mindbox/MindboxLogger/SDKLogsManager.swift b/Mindbox/MindboxLogger/SDKLogsManager.swift index 196270ecc..56c3e4d75 100644 --- a/Mindbox/MindboxLogger/SDKLogsManager.swift +++ b/Mindbox/MindboxLogger/SDKLogsManager.swift @@ -28,10 +28,12 @@ class SDKLogsManager: SDKLogsManagerProtocol { } func sendLogs(logs: [Monitoring.Logs]) { - guard !logs.isEmpty else { return } + guard !logs.isEmpty, + let deviceUUID = persistenceStorage.deviceUUID, !deviceUUID.isEmpty else { return } + let deviceTarget = MD5Hash(deviceUUID: deviceUUID) var handledLogsRequestIds = persistenceStorage.handledlogRequestIds ?? [] for log in logs { - if !handledLogsRequestIds.contains(log.requestId) && persistenceStorage.deviceUUID == log.deviceUUID.uppercased() { + if !handledLogsRequestIds.contains(log.requestId) && deviceTarget == MD5Hash(hex: log.target) { handledLogsRequestIds.append(log.requestId) guard let from = log.from.toDate(withFormat: .utc), let to = log.to.toDate(withFormat: .utc) else { From 6a87db5f71d2de2695423e79ed25abfd4a499848 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:20:04 +0300 Subject: [PATCH 13/23] MOBILE-340 Update monitoring tests and fixtures to target format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixtures reuse android-sdk #749 values so all platforms verify the same vectors; adds legacy-format, both-fields and blank-target cases and exercises the CC_MD5 fallback directly — no iOS 12 simulator exists in CI. --- .../MonitoringConfigParsingTests.swift | 31 +++++-- .../ConfigJsonStub/ConfigABTestsError.json | 4 +- .../ConfigABTestsOneElementError.json | 4 +- .../ConfigABTestsOneElementTypeError.json | 4 +- .../ConfigABTestsTypeError.json | 4 +- .../ConfigJsonStub/ConfigInAppsError.json | 4 +- .../ConfigJsonStub/ConfigInAppsTypeError.json | 4 +- .../ConfigJsonStub/ConfigMonitoringError.json | 4 +- .../ConfigJsonStub/ConfigSettingsError.json | 4 +- .../ConfigSettingsTypeError.json | 4 +- ...igWithSettingsABTestsMonitoringInapps.json | 4 +- .../MonitoringJsonStubs/MonitoringConfig.json | 4 +- .../MonitoringLogsBothFieldsFormat.json | 11 +++ .../MonitoringLogsElementsMixedError.json | 4 +- .../MonitoringLogsError.json | 4 +- .../MonitoringLogsOldDeviceUuidFormat.json | 16 ++++ .../MonitoringLogsOneElementError.json | 4 +- .../MonitoringLogsOneElementTypeError.json | 4 +- .../MonitoringLogsTwoElementsError.json | 6 +- .../MonitoringLogsTwoElementsTypeError.json | 4 +- MindboxTests/MindboxLogger/MD5HashTests.swift | 52 ++++++++++++ .../MindboxLogger/SDKLogManagerTests.swift | 81 +++++++++++++++---- 22 files changed, 204 insertions(+), 57 deletions(-) create mode 100644 MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json create mode 100644 MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json create mode 100644 MindboxTests/MindboxLogger/MD5HashTests.swift diff --git a/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift b/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift index ee8f00a53..db461edc7 100644 --- a/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift +++ b/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift @@ -20,11 +20,14 @@ fileprivate enum MonitoringConfig: String, Configurable { case monitoringLogsTypeError = "MonitoringLogsTypeError" // Type of `logs` is Int instead of FailableDecodableArray case monitoringLogsOneElementError = "MonitoringLogsOneElementError" // Key is `request` instead of `requestId` - case monitoringLogsTwoElementsError = "MonitoringLogsTwoElementsError" // Key is `request` instead of `requestId` and key is `device` instead of `deviceUUID` + case monitoringLogsTwoElementsError = "MonitoringLogsTwoElementsError" // Key is `request` instead of `requestId` and key is `toTest` instead of `to` case monitoringLogsOneElementTypeError = "MonitoringLogsOneElementTypeError" // Type of `requestId` is Int instead of String case monitoringLogsTwoElementsTypeError = "MonitoringLogsTwoElementsTypeError" // Type of `requestId` is Int instead of String and type of `from` is Object instead `String` case monitoringLogsElementsMixedError = "MonitoringLogsElementsMixedError" // Type of `requestId` is Int instead of String and key is `fromTest` instead of `from` + + case monitoringLogsOldDeviceUuidFormat = "MonitoringLogsOldDeviceUuidFormat" // Legacy element with `deviceUUID` instead of `target`, next to a new-format element + case monitoringLogsBothFieldsFormat = "MonitoringLogsBothFieldsFormat" // Element carries both `deviceUUID` and `target` } final class MonitoringConfigParsingTests: XCTestCase { @@ -34,10 +37,12 @@ final class MonitoringConfigParsingTests: XCTestCase { let config = try! MonitoringConfig.configWithMonitoring.getConfig() XCTAssertEqual(config.logs.elements.count, 2) + XCTAssertEqual(config.logs.elements.first?.target, "334db432a8f72f64a89664682f7bc032") + XCTAssertEqual(config.logs.elements.last?.target, "248eccb79da2bbca61c133c59e4a1516") for log in config.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.deviceUUID) + XCTAssertNotNil(log.target) XCTAssertNotNil(log.requestId) XCTAssertNotNil(log.from) XCTAssertNotNil(log.to) @@ -68,7 +73,7 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config!.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.deviceUUID) + XCTAssertNotNil(log.target) XCTAssertNotNil(log.requestId) XCTAssertNotNil(log.from) XCTAssertNotNil(log.to) @@ -85,7 +90,7 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config!.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.deviceUUID) + XCTAssertNotNil(log.target) XCTAssertNotNil(log.requestId) XCTAssertNotNil(log.from) XCTAssertNotNil(log.to) @@ -94,7 +99,7 @@ final class MonitoringConfigParsingTests: XCTestCase { } func test_MonitoringConfig_withLogsTwoElementsError_shouldParseSuccessfullyRemainsElements() { - // Key is `request` instead `requestId` and key is `device` instead of `deviceUUID` + // Key is `request` instead of `requestId` and key is `toTest` instead of `to` let config = try? MonitoringConfig.monitoringLogsTwoElementsError.getConfig() XCTAssertNotNil(config?.logs, "Monitoring must be parsed successfully") @@ -116,4 +121,20 @@ final class MonitoringConfigParsingTests: XCTestCase { XCTAssertEqual(config?.logs.elements.count, 0) } + + func test_MonitoringConfig_withOldDeviceUuidFormat_shouldDropLegacyElements() { + // First element has legacy `deviceUUID` instead of `target`, second is new-format + let config = try! MonitoringConfig.monitoringLogsOldDeviceUuidFormat.getConfig() + + XCTAssertEqual(config.logs.elements.count, 1, "Legacy element must be dropped, new-format element must remain") + XCTAssertEqual(config.logs.elements.first?.target, "248eccb79da2bbca61c133c59e4a1516") + } + + func test_MonitoringConfig_withBothFieldsInElement_shouldParseTarget() { + // Element carries both legacy `deviceUUID` and new `target`; `target` is decoded, `deviceUUID` is ignored + let config = try! MonitoringConfig.monitoringLogsBothFieldsFormat.getConfig() + + XCTAssertEqual(config.logs.elements.count, 1) + XCTAssertEqual(config.logs.elements.first?.target, "334db432a8f72f64a89664682f7bc032") + } } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json index 37f540775..51ec836fa 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json index 875e0567d..e1cb5919a 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json index f803ab032..dc192258e 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsOneElementTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json index fe6dc90ea..33c8247b5 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigABTestsTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json index f6cd928a0..b322c3c51 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json index 540273ed4..620a3f9ec 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigInAppsTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json index fe3b063e2..473564961 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigMonitoringError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json index 3425bc5e1..edb33c36a 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json index 0c0869660..41c44562b 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigSettingsTypeError.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json index 67d2ce5de..d0bfe9a44 100644 --- a/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json +++ b/MindboxTests/ConfigParsing/stubs/Config/ConfigJsonStub/ConfigWithSettingsABTestsMonitoringInapps.json @@ -3,13 +3,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json index 052ceff8b..ddef5c783 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringConfig.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json new file mode 100644 index 000000000..0b64b4566 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsBothFieldsFormat.json @@ -0,0 +1,11 @@ +{ + "logs": [ + { + "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", + "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", + "from": "2024-02-12T10:00:00", + "to": "2024-02-14T20:30:00" + } + ] +} diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json index b3876f263..bd8f53e86 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsElementsMixedError.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": 1, - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "fromTest": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json index a439ea941..3b51e84b6 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsError.json @@ -2,13 +2,13 @@ "logsTests": [ { "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json new file mode 100644 index 000000000..3422af271 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOldDeviceUuidFormat.json @@ -0,0 +1,16 @@ +{ + "logs": [ + { + "requestId": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", + "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "from": "2024-02-12T10:00:00", + "to": "2024-02-14T20:30:00" + }, + { + "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", + "target": "248eccb79da2bbca61c133c59e4a1516", + "from": "2023-02-10T00:00:00", + "to": "2023-03-01T20:30:00" + } + ] +} diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json index 9e9bbc571..d9f8bf605 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementError.json @@ -2,13 +2,13 @@ "logs": [ { "request": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json index b983b1882..c97b31f22 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsOneElementTypeError.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": 1, - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json index 0a278d1d3..b7cd749df 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsError.json @@ -2,15 +2,15 @@ "logs": [ { "request": "bb978cd7-ce4c-4239-a5d2-4b7e5d4fb5b9", - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "device": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": "2023-02-10T00:00:00", - "to": "2023-03-01T20:30:00" + "toTest": "2023-03-01T20:30:00" } ] } diff --git a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json index 67956b664..536d8a95d 100644 --- a/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Monitoring/MonitoringJsonStubs/MonitoringLogsTwoElementsTypeError.json @@ -2,13 +2,13 @@ "logs": [ { "requestId": 1, - "deviceUUID": "216e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "334db432a8f72f64a89664682f7bc032", "from": "2024-02-12T10:00:00", "to": "2024-02-14T20:30:00" }, { "requestId": "8e829a63-a5c3-4e1c-a772-41fd61f8f331", - "deviceUUID": "126e6225-3170-4089-a6f0-3d1ed8f64153", + "target": "248eccb79da2bbca61c133c59e4a1516", "from": {}, "to": "2023-03-01T20:30:00" } diff --git a/MindboxTests/MindboxLogger/MD5HashTests.swift b/MindboxTests/MindboxLogger/MD5HashTests.swift new file mode 100644 index 000000000..b3c06d4ab --- /dev/null +++ b/MindboxTests/MindboxLogger/MD5HashTests.swift @@ -0,0 +1,52 @@ +// +// MD5HashTests.swift +// MindboxTests +// +// Created by Sergei Semko on 8/3/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +// Shared vectors from android-sdk PR #749 (MOBILE-281): the same deviceUUID must +// produce the same target on iOS, Android and the server. +private let sharedMD5Vectors: [(String, String)] = [ + ("216e6225-3170-4089-a6f0-3d1ed8f64153", "334db432a8f72f64a89664682f7bc032"), + ("126e6225-3170-4089-a6f0-3d1ed8f64153", "248eccb79da2bbca61c133c59e4a1516"), + // hash with leading zeros — catches broken hex padding + ("7e570ddf-8270-40a8-a369-b584ff5e9ff0", "000baa91b37b3c201e3f8604c7845201"), + // an uppercase uuid produces the same hash: the input is lowercased before hashing + ("216E6225-3170-4089-A6F0-3D1ED8F64153", "334db432a8f72f64a89664682f7bc032"), + // md5 of zero bytes — the well-known constant + ("", "d41d8cd98f00b204e9800998ecf8427e") +] + +@Suite("MD5Hash") +struct MD5HashTests { + + @Test(arguments: sharedMD5Vectors) + func matchesSharedDeviceUUIDVectors(deviceUUID: String, expectedHex: String) { + #expect(MD5Hash(deviceUUID: deviceUUID) == MD5Hash(hex: expectedHex)) + } + + @Test + func normalizesHexCaseForComparison() { + #expect(MD5Hash(hex: "334DB432A8F72F64A89664682F7BC032") == MD5Hash(hex: "334db432a8f72f64a89664682f7bc032")) + } + + @Test + func differentUUIDsProduceDifferentHashes() { + #expect(MD5Hash(deviceUUID: "216e6225-3170-4089-a6f0-3d1ed8f64153") != MD5Hash(deviceUUID: "126e6225-3170-4089-a6f0-3d1ed8f64153")) + } + + // Deprecated-annotated so the CC_MD5 fallback call compiles without a warning. + @available(iOS, introduced: 12.0, deprecated: 13.0, message: "Exercises the iOS 12 CC_MD5 fallback") + @Test(arguments: sharedMD5Vectors) + func commonCryptoFallbackMatchesSharedVectors(deviceUUID: String, expectedHex: String) { + let digest = MD5Hash.commonCryptoMD5(Data(deviceUUID.lowercased().utf8)) + let hex = digest.map { String(format: "%02x", $0) }.joined() + #expect(hex == expectedHex) + } +} diff --git a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift index 378fff2fa..2c9818b74 100644 --- a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift +++ b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift @@ -12,6 +12,14 @@ import XCTest final class SDKLogManagerTests: XCTestCase { + // Shared cross-platform vectors (android-sdk PR #749): target is the MD5 hex of the lowercased deviceUUID. + private enum Stub { + static let deviceUUID = "216E6225-3170-4089-A6F0-3D1ED8F64153" // persisted uppercased, as the real SDK stores it + static let target = "334db432a8f72f64a89664682f7bc032" + static let foreignTarget = "248eccb79da2bbca61c133c59e4a1516" // another device's target + static let emptyStringTarget = "d41d8cd98f00b204e9800998ecf8427e" // md5 of "" + } + var eventRepositoryMock: EventRepositoryMock! var logsManager: SDKLogsManager! var persistenceStorageMock: PersistenceStorage! @@ -19,7 +27,7 @@ final class SDKLogManagerTests: XCTestCase { override func setUp() { super.setUp() persistenceStorageMock = DI.injectOrFail(PersistenceStorage.self) - persistenceStorageMock.deviceUUID = "2" + persistenceStorageMock.deviceUUID = Stub.deviceUUID eventRepositoryMock = DI.injectOrFail(EventRepositoryMock.self) logsManager = DI.injectOrFail(SDKLogsManagerProtocol.self) as? SDKLogsManager } @@ -31,34 +39,63 @@ final class SDKLogManagerTests: XCTestCase { super.tearDown() } - func testBody_withWrongUUID_shouldReturnNil() { - let dateFrom = Date().addingTimeInterval(-60) - let dateTo = Date() - let logs: [Monitoring.Logs] = [ - .init(requestId: "1", - deviceUUID: "2", - from: dateFrom.toString(withFormat: .utc), - to: dateTo.toString(withFormat: .utc)) - ] - persistenceStorageMock.deviceUUID = "3" - logsManager.sendLogs(logs: logs) + func testBody_withMatchingTarget_shouldSendLogs() { + logsManager.sendLogs(logs: [makeLog(target: Stub.target)]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 1) + } + + func testBody_withForeignTarget_shouldNotSendLogs() { + logsManager.sendLogs(logs: [makeLog(target: Stub.foreignTarget)]) XCTAssertNil(eventRepositoryMock.lastBody) XCTAssertEqual(eventRepositoryMock.requests.count, 0) } + func testBody_withUppercasedTargetHex_shouldSendLogs() { + logsManager.sendLogs(logs: [makeLog(target: Stub.target.uppercased())]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 1) + } + func testBody_withRepeatedRequestID_shouldReturnOneRequest() { - let dateFrom = Date().addingTimeInterval(-60).toString(withFormat: .utc) - let dateTo = Date().toString(withFormat: .utc) - let logs: [Monitoring.Logs] = [ - .init(requestId: "1", deviceUUID: "2", from: dateFrom, to: dateTo), - .init(requestId: "1", deviceUUID: "2", from: dateFrom, to: dateTo) + let logs = [ + makeLog(requestId: "1", target: Stub.target), + makeLog(requestId: "1", target: Stub.target) ] logsManager.sendLogs(logs: logs) + XCTAssertEqual(eventRepositoryMock.requests.count, 1) } + func testBody_withNilDeviceUUID_shouldNotSendLogs() { + persistenceStorageMock.deviceUUID = nil + let logs = [ + makeLog(requestId: "1", target: Stub.target), + makeLog(requestId: "2", target: Stub.emptyStringTarget) + ] + + logsManager.sendLogs(logs: logs) + + XCTAssertEqual(eventRepositoryMock.requests.count, 0) + } + + func testBody_withEmptyDeviceUUID_shouldNotSendLogs() { + // A blank persisted deviceUUID must match nothing, even a config carrying md5(""). + persistenceStorageMock.deviceUUID = "" + logsManager.sendLogs(logs: [makeLog(target: Stub.emptyStringTarget)]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 0) + } + + func testBody_withEmptyTarget_shouldNotSendLogs() { + // Parity with Android's validator: a blank target must never match. + logsManager.sendLogs(logs: [makeLog(target: "")]) + + XCTAssertEqual(eventRepositoryMock.requests.count, 0) + } + func test_status_shouldReturnOk() throws { let dateFrom = Date().addingTimeInterval(-60) let dateTo = Date() @@ -126,4 +163,14 @@ final class SDKLogManagerTests: XCTestCase { XCTAssertEqual(actualLogs.count, 1) XCTAssertEqual(actualLogs, expectedResult) } + + private func makeLog(requestId: String = "1", + target: String, + from: Date = Date().addingTimeInterval(-60), + to: Date = Date()) -> Monitoring.Logs { + .init(requestId: requestId, + target: target, + from: from.toString(withFormat: .utc), + to: to.toString(withFormat: .utc)) + } } From 63f1b1bee2909573477c9992ca5712253c28cba7 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:21:04 +0300 Subject: [PATCH 14/23] MOBILE-340 Don't abort log-request processing on malformed dates A guard inside the loop returned from sendLogs before handled request ids were persisted, so one malformed entry re-sent earlier valid entries on every config apply and blocked later ones. Continue to the next entry instead. --- Mindbox/MindboxLogger/SDKLogsManager.swift | 2 +- MindboxTests/MindboxLogger/SDKLogManagerTests.swift | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Mindbox/MindboxLogger/SDKLogsManager.swift b/Mindbox/MindboxLogger/SDKLogsManager.swift index 56c3e4d75..8b868c9dd 100644 --- a/Mindbox/MindboxLogger/SDKLogsManager.swift +++ b/Mindbox/MindboxLogger/SDKLogsManager.swift @@ -37,7 +37,7 @@ class SDKLogsManager: SDKLogsManagerProtocol { handledLogsRequestIds.append(log.requestId) guard let from = log.from.toDate(withFormat: .utc), let to = log.to.toDate(withFormat: .utc) else { - return + continue } do { diff --git a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift index 2c9818b74..1a9fc3675 100644 --- a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift +++ b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift @@ -96,6 +96,18 @@ final class SDKLogManagerTests: XCTestCase { XCTAssertEqual(eventRepositoryMock.requests.count, 0) } + func testBody_withMalformedDatesEntry_shouldStillProcessSubsequentEntries() { + let logs = [ + Monitoring.Logs(requestId: "1", target: Stub.target, from: "not-a-date", to: "not-a-date"), + makeLog(requestId: "2", target: Stub.target) + ] + + logsManager.sendLogs(logs: logs) + + XCTAssertEqual(eventRepositoryMock.requests.count, 1, "The valid entry after the malformed one must still be sent") + XCTAssertEqual(persistenceStorageMock.handledlogRequestIds, ["1", "2"], "Both entries must be marked handled and persisted") + } + func test_status_shouldReturnOk() throws { let dateFrom = Date().addingTimeInterval(-60) let dateTo = Date() From abee4b98fbfd7d56f57c24fe19057e719fbecd47 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:34:32 +0300 Subject: [PATCH 15/23] MOBILE-340 Log monitoring log-request processing decisions The whole path was silent: skips had no trace and the empty catch even swallowed body-build errors, so support couldn't tell why a device never answered a log request. Every decision now logs its reason, and skip lines land in the same log DB the next successful fetch delivers. --- .../InAppConfigurationManager.swift | 6 ++- Mindbox/MindboxLogger/SDKLogsManager.swift | 43 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 5cd4adf39..9f9585553 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -120,10 +120,12 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { } private func sendMonitoringLogsIfNeeded(_ monitoring: Monitoring?) { - guard let monitoring = monitoring, - let logsManager = DI.inject(SDKLogsManagerProtocol.self) else { + guard let monitoring = monitoring else { return } + guard let logsManager = DI.inject(SDKLogsManagerProtocol.self) else { + Logger.common(message: "[SDKLogs] Unable to send monitoring logs: SDKLogsManager is not registered in DI", level: .error, category: .general) return } + Logger.common(message: "[SDKLogs] Monitoring config contains \(monitoring.logs.elements.count) log request(s)", level: .debug, category: .general) logsManager.sendLogs(logs: monitoring.logs.elements) } diff --git a/Mindbox/MindboxLogger/SDKLogsManager.swift b/Mindbox/MindboxLogger/SDKLogsManager.swift index 8b868c9dd..35f18319a 100644 --- a/Mindbox/MindboxLogger/SDKLogsManager.swift +++ b/Mindbox/MindboxLogger/SDKLogsManager.swift @@ -28,23 +28,40 @@ class SDKLogsManager: SDKLogsManagerProtocol { } func sendLogs(logs: [Monitoring.Logs]) { - guard !logs.isEmpty, - let deviceUUID = persistenceStorage.deviceUUID, !deviceUUID.isEmpty else { return } + guard !logs.isEmpty else { return } + guard let deviceUUID = persistenceStorage.deviceUUID, !deviceUUID.isEmpty else { + Logger.common(message: "[SDKLogs] Skip monitoring logs: deviceUUID is missing", level: .error, category: .general) + return + } let deviceTarget = MD5Hash(deviceUUID: deviceUUID) var handledLogsRequestIds = persistenceStorage.handledlogRequestIds ?? [] for log in logs { - if !handledLogsRequestIds.contains(log.requestId) && deviceTarget == MD5Hash(hex: log.target) { - handledLogsRequestIds.append(log.requestId) - guard let from = log.from.toDate(withFormat: .utc), - let to = log.to.toDate(withFormat: .utc) else { - continue - } + guard !handledLogsRequestIds.contains(log.requestId) else { + Logger.common(message: "[SDKLogs] Skip request \(log.requestId): already handled", level: .debug, category: .general) + continue + } + guard deviceTarget == MD5Hash(hex: log.target) else { + Logger.common(message: "[SDKLogs] Skip request \(log.requestId): target \(log.target) doesn't match device target \(deviceTarget.hex)", level: .debug, category: .general) + continue + } + handledLogsRequestIds.append(log.requestId) + guard let from = log.from.toDate(withFormat: .utc), + let to = log.to.toDate(withFormat: .utc) else { + Logger.common(message: "[SDKLogs] Skip request \(log.requestId): malformed dates from: \"\(log.from)\", to: \"\(log.to)\"", level: .error, category: .general) + continue + } - do { - let body = try getBody(from: from, to: to, requestID: log.requestId) - let event = Event(type: .sdkLogs, body: BodyEncoder(encodable: body).body) - eventRepository.send(event: event) { _ in } - } catch {} + do { + let body = try getBody(from: from, to: to, requestID: log.requestId) + let event = Event(type: .sdkLogs, body: BodyEncoder(encodable: body).body) + Logger.common(message: "[SDKLogs] Sending logs for request \(log.requestId), period \(log.from) – \(log.to), status: \(body.status), lines: \(body.content.count)", level: .info, category: .general) + eventRepository.send(event: event) { result in + if case let .failure(error) = result { + Logger.common(message: "[SDKLogs] Sending logs for request \(log.requestId) failed: \(error.localizedDescription)", level: .error, category: .general) + } + } + } catch { + Logger.common(message: "[SDKLogs] Failed to build logs body for request \(log.requestId): \(error.localizedDescription)", level: .error, category: .general) } } From 0cb721ff26532e5c92fe0155c4ef3cc5a4c8fedc Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:34:32 +0300 Subject: [PATCH 16/23] MOBILE-340 Pair case-sensitivity vectors in MD5 tests Lowercase and uppercase rows of the same uuid now sit together with the raw-uppercase md5 counterexample in a comment, so the lowercasing contract is visible at a glance. --- MindboxTests/MindboxLogger/MD5HashTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/MindboxTests/MindboxLogger/MD5HashTests.swift b/MindboxTests/MindboxLogger/MD5HashTests.swift index b3c06d4ab..6ddb996f6 100644 --- a/MindboxTests/MindboxLogger/MD5HashTests.swift +++ b/MindboxTests/MindboxLogger/MD5HashTests.swift @@ -14,11 +14,12 @@ import Testing // produce the same target on iOS, Android and the server. private let sharedMD5Vectors: [(String, String)] = [ ("216e6225-3170-4089-a6f0-3d1ed8f64153", "334db432a8f72f64a89664682f7bc032"), + // the same uuid uppercased → the same hash: MD5Hash lowercases the input before hashing + // (raw md5 of the UPPERCASE string itself would be 2a482810731225da19199c17a956a560) + ("216E6225-3170-4089-A6F0-3D1ED8F64153", "334db432a8f72f64a89664682f7bc032"), ("126e6225-3170-4089-a6f0-3d1ed8f64153", "248eccb79da2bbca61c133c59e4a1516"), // hash with leading zeros — catches broken hex padding ("7e570ddf-8270-40a8-a369-b584ff5e9ff0", "000baa91b37b3c201e3f8604c7845201"), - // an uppercase uuid produces the same hash: the input is lowercased before hashing - ("216E6225-3170-4089-A6F0-3D1ED8F64153", "334db432a8f72f64a89664682f7bc032"), // md5 of zero bytes — the well-known constant ("", "d41d8cd98f00b204e9800998ecf8427e") ] From bf05e7a062fa52dbd6386a671bde704790a0ed48 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:02:51 +0300 Subject: [PATCH 17/23] MOBILE-340 Assert decoded monitoring fields are non-empty XCTAssertNotNil on non-optional strings could never fail (pattern predates this PR); non-empty checks actually guard the decoded values. --- .../MonitoringConfigParsingTests.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift b/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift index db461edc7..3f5bd98ff 100644 --- a/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift +++ b/MindboxTests/ConfigParsing/Monitoring/MonitoringConfigParsingTests.swift @@ -42,10 +42,10 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.target) - XCTAssertNotNil(log.requestId) - XCTAssertNotNil(log.from) - XCTAssertNotNil(log.to) + XCTAssertFalse(log.target.isEmpty) + XCTAssertFalse(log.requestId.isEmpty) + XCTAssertFalse(log.from.isEmpty) + XCTAssertFalse(log.to.isEmpty) } } } @@ -73,10 +73,10 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config!.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.target) - XCTAssertNotNil(log.requestId) - XCTAssertNotNil(log.from) - XCTAssertNotNil(log.to) + XCTAssertFalse(log.target.isEmpty) + XCTAssertFalse(log.requestId.isEmpty) + XCTAssertFalse(log.from.isEmpty) + XCTAssertFalse(log.to.isEmpty) } } } @@ -90,10 +90,10 @@ final class MonitoringConfigParsingTests: XCTestCase { for log in config!.logs.elements { XCTContext.runActivity(named: "Check log \(log) is in `config.logs.elements`") { _ in - XCTAssertNotNil(log.target) - XCTAssertNotNil(log.requestId) - XCTAssertNotNil(log.from) - XCTAssertNotNil(log.to) + XCTAssertFalse(log.target.isEmpty) + XCTAssertFalse(log.requestId.isEmpty) + XCTAssertFalse(log.from.isEmpty) + XCTAssertFalse(log.to.isEmpty) } } } From 77cee1cdf290672177d5f98f2cdba0c3bd76020f Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:02:51 +0300 Subject: [PATCH 18/23] MOBILE-340 Document CryptoKit weak linking at the import Verified via otool: LC_LOAD_WEAK_DYLIB, so the import cannot break iOS 12 startup. The note preempts the recurring review question. --- Mindbox/MindboxLogger/MD5Hash.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Mindbox/MindboxLogger/MD5Hash.swift b/Mindbox/MindboxLogger/MD5Hash.swift index 89c5a877d..a23ad1e26 100644 --- a/Mindbox/MindboxLogger/MD5Hash.swift +++ b/Mindbox/MindboxLogger/MD5Hash.swift @@ -7,6 +7,9 @@ // import CommonCrypto +// CryptoKit is auto-weak-linked (deployment target < iOS 13, LC_LOAD_WEAK_DYLIB +// in the linked binary), so this import cannot break iOS 12 startup; the +// #available guard below keeps its symbols untouched there. import CryptoKit import Foundation From b5b1998174f3dc25f3c48f5acea507748793ea05 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:37:41 +0300 Subject: [PATCH 19/23] MOBILE-340 Drop ticket and PR references from comments --- Mindbox/MindboxLogger/MD5Hash.swift | 7 +++---- MindboxTests/MindboxLogger/MD5HashTests.swift | 4 ++-- MindboxTests/MindboxLogger/SDKLogManagerTests.swift | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Mindbox/MindboxLogger/MD5Hash.swift b/Mindbox/MindboxLogger/MD5Hash.swift index a23ad1e26..cf14c9e10 100644 --- a/Mindbox/MindboxLogger/MD5Hash.swift +++ b/Mindbox/MindboxLogger/MD5Hash.swift @@ -13,10 +13,9 @@ import CommonCrypto import CryptoKit import Foundation -/// Match token for monitoring log requests (MOBILE-340): MD5 hex of the lowercased -/// deviceUUID. Both the hash input and the hex are lowercased, so comparison is -/// case-insensitive. Must stay in sync with the server contract and Android's -/// `Md5Hash` (android-sdk #749). +/// Match token for monitoring log requests: MD5 hex of the lowercased deviceUUID. +/// Both the hash input and the hex are lowercased, so comparison is case-insensitive. +/// Must stay in sync with the server contract and the Android SDK. struct MD5Hash: Equatable { let hex: String diff --git a/MindboxTests/MindboxLogger/MD5HashTests.swift b/MindboxTests/MindboxLogger/MD5HashTests.swift index 6ddb996f6..0a0711d5a 100644 --- a/MindboxTests/MindboxLogger/MD5HashTests.swift +++ b/MindboxTests/MindboxLogger/MD5HashTests.swift @@ -10,8 +10,8 @@ import Foundation import Testing @testable import Mindbox -// Shared vectors from android-sdk PR #749 (MOBILE-281): the same deviceUUID must -// produce the same target on iOS, Android and the server. +// Shared cross-platform vectors: the same deviceUUID must produce the same target +// on iOS, Android and the server. private let sharedMD5Vectors: [(String, String)] = [ ("216e6225-3170-4089-a6f0-3d1ed8f64153", "334db432a8f72f64a89664682f7bc032"), // the same uuid uppercased → the same hash: MD5Hash lowercases the input before hashing diff --git a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift index 1a9fc3675..13067c49b 100644 --- a/MindboxTests/MindboxLogger/SDKLogManagerTests.swift +++ b/MindboxTests/MindboxLogger/SDKLogManagerTests.swift @@ -12,7 +12,7 @@ import XCTest final class SDKLogManagerTests: XCTestCase { - // Shared cross-platform vectors (android-sdk PR #749): target is the MD5 hex of the lowercased deviceUUID. + // Shared cross-platform vectors: target is the MD5 hex of the lowercased deviceUUID. private enum Stub { static let deviceUUID = "216E6225-3170-4089-A6F0-3D1ED8F64153" // persisted uppercased, as the real SDK stores it static let target = "334db432a8f72f64a89664682f7bc032" From b88d2e7bec1a35fdb5666503ae135ee32e75fc51 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:59:03 +0300 Subject: [PATCH 20/23] MOBILE-0000 Add start and app-state separators to SDK logs Multi-day log dumps read as one solid stream. A heavy banner at process start, a medium one at inapp-session rollover and light foreground/ background lines let support find launch and session boundaries at a glance. --- Mindbox/CoreController/CoreController.swift | 1 + .../InAppMessages/InappSessionManager/InappSessionManager.swift | 1 + Mindbox/SessionManager/MBSessionManager.swift | 2 ++ 3 files changed, 4 insertions(+) diff --git a/Mindbox/CoreController/CoreController.swift b/Mindbox/CoreController/CoreController.swift index 668a05583..4428014e5 100644 --- a/Mindbox/CoreController/CoreController.swift +++ b/Mindbox/CoreController/CoreController.swift @@ -259,6 +259,7 @@ final class CoreController { controllerQueue: DispatchQueue = DispatchQueue(label: "com.Mindbox.controllerQueue"), userVisitManager: UserVisitManagerProtocol ) { + Logger.common(message: "════════════════ [Cold start] SDK initialization ════════════════", level: .info, category: .general) self.persistenceStorage = persistenceStorage self.utilitiesFetcher = utilitiesFetcher self.databaseRepository = databaseRepository diff --git a/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift b/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift index e27d9eb36..58b820e47 100644 --- a/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift +++ b/Mindbox/InAppMessages/InappSessionManager/InappSessionManager.swift @@ -70,6 +70,7 @@ final class InappSessionManager: InappSessionManagerProtocol { let timeBetweenVisitsSeconds = now.timeIntervalSince(lastTimestamp) if timeBetweenVisitsSeconds > Double(sessionTimeInSeconds) { updatingInappSession = true + Logger.common(message: "──────────────── [New session] ────────────────", level: .info, category: .general) Logger.common(message: "[InappSessionManager] Session expired. Need to update session...") updateInappSession() } else { diff --git a/Mindbox/SessionManager/MBSessionManager.swift b/Mindbox/SessionManager/MBSessionManager.swift index cc8941611..29dc92ef9 100644 --- a/Mindbox/SessionManager/MBSessionManager.swift +++ b/Mindbox/SessionManager/MBSessionManager.swift @@ -34,6 +34,7 @@ final class MBSessionManager { object: nil, queue: nil ) { [weak self] _ in + Logger.common(message: "········ [Foreground] app did become active ········", level: .info, category: .general) self?.isActive = true } @@ -42,6 +43,7 @@ final class MBSessionManager { object: nil, queue: nil ) { [weak self] _ in + Logger.common(message: "········ [Background] app did enter background ········", level: .info, category: .general) self?.isActive = false } From 4837b52e905dc8f1b048108eccef8ab1e2032f71 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:41:31 +0300 Subject: [PATCH 21/23] MOBILE-0000 Drop ticket and PR references from comments and test names --- Mindbox/DI/Injections/InjectReplaceable.swift | 4 ++-- Mindbox/PersistenceStorage/MBPersistenceStorage.swift | 2 +- Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift | 4 ++-- .../Shared/Extensions/MBLoggerUtilitiesFetcher.swift | 2 +- MindboxLoggerTests/FileManagerStoreURLTests.swift | 2 +- MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift | 4 ++-- MindboxTests/Configuration/MBConfigurationTests.swift | 2 +- MindboxTests/DI/AppGroupUnavailableTests.swift | 6 +++--- .../Tests/TransparentViewSyncOperationResponseTests.swift | 4 ++-- MindboxTests/MindboxOperationsTests.swift | 2 +- MindboxTests/MindboxTests.swift | 2 +- MindboxTests/Network/MBEventRepositorySendRawTests.swift | 2 +- .../Network/MBNetworkFetcherResponseHandlingTests.swift | 2 +- MindboxTests/Network/OperationResponseTests.swift | 6 +++--- MindboxTests/Network/OperationsURLRoutingTests.swift | 4 ++-- 15 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Mindbox/DI/Injections/InjectReplaceable.swift b/Mindbox/DI/Injections/InjectReplaceable.swift index fa41f206d..827523c67 100644 --- a/Mindbox/DI/Injections/InjectReplaceable.swift +++ b/Mindbox/DI/Injections/InjectReplaceable.swift @@ -34,13 +34,13 @@ extension MBContainer { guard !appGroup.isEmpty else { // App Group unavailable (already reported by the fetcher) — fall back to - // `.standard` so the SDK keeps working instead of crashing the host (issue #705). + // `.standard` so the SDK keeps working instead of crashing the host. // A later App Group fix re-registers the install; see `AppGroupStorageTransitionReporter`. return MBPersistenceStorage(defaults: .standard) } guard let defaults = UserDefaults(suiteName: appGroup) else { - // Unreachable for a resolved App Group id; degrade without trapping (issue #705). + // Unreachable for a resolved App Group id; degrade without trapping. Logger.common(message: "[PersistenceStorage] UserDefaults(suiteName: \(appGroup)) failed; using .standard.", level: .fault, category: .general) return MBPersistenceStorage(defaults: .standard) } diff --git a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift index 357ca885f..62ceedb78 100644 --- a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift @@ -370,7 +370,7 @@ extension MBPersistenceStorage { } } -/// Reports (issue #705 follow-up) a fallback-then-recovery fingerprint: install state in BOTH the +/// Reports a fallback-then-recovery fingerprint: install state in BOTH the /// `.standard` fallback and the App Group suite. Read-only by design (cleanup deferred to a future /// migration). Runs after re-registration, so it fires on the recovery launch itself and on every /// cold start while the fingerprint persists. diff --git a/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift b/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift index a54a83cad..de53e8d66 100644 --- a/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift +++ b/Mindbox/Utilities/UtilitiesFetcher/MBUtilitiesFetcher.swift @@ -33,9 +33,9 @@ class MBUtilitiesFetcher: UtilitiesFetcher { /// (events database + `UserDefaults` suite). /// /// Returns `""` when the host bundle id is missing or the container is unavailable, - /// so the SDK falls back to local storage instead of crashing the host (issue #705). + /// so the SDK falls back to local storage instead of crashing the host. /// Must never trap — not even in Debug: Debug builds on device farms routinely lack a - /// configured App Group, the exact scenario #705 is about. Surfaced as a `.fault` log. + /// configured App Group — the exact scenario this guards. Surfaced as a `.fault` log. var applicationGroupIdentifier: String { guard let hostApplicationName = hostApplicationName else { Logger.common(message: "[MBUtilitiesFetcher] Host application bundle identifier is unavailable", level: .fault, category: .general) diff --git a/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift b/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift index 49cd2ee7a..342b1c0dc 100644 --- a/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift +++ b/MindboxLogger/Shared/Extensions/MBLoggerUtilitiesFetcher.swift @@ -21,7 +21,7 @@ class MBLoggerUtilitiesFetcher { /// /// `nil` makes `LoggerDatabaseLoader` fall back to the app's local caches store, so the /// logger keeps working instead of being disabled. Must never trap — the SDK must not - /// bring down its host over an unavailable container, on simulator or device (issue #705). + /// bring down its host over an unavailable container, on simulator or device. var applicationGroupIdentifier: String? { guard let hostApplicationName = hostApplicationName else { return nil diff --git a/MindboxLoggerTests/FileManagerStoreURLTests.swift b/MindboxLoggerTests/FileManagerStoreURLTests.swift index 65a918c46..59c388f79 100644 --- a/MindboxLoggerTests/FileManagerStoreURLTests.swift +++ b/MindboxLoggerTests/FileManagerStoreURLTests.swift @@ -10,7 +10,7 @@ import Foundation import CoreData @testable import MindboxLogger -/// Regression coverage for App Group store-URL resolution (issue #705). +/// Regression coverage for App Group store-URL resolution. /// /// Previously `FileManager.storeURL(for:databaseName:)` called `fatalError` when /// the shared container was unavailable, which crashed the host straight through diff --git a/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift b/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift index 37d5e55ae..ba51ac593 100644 --- a/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift +++ b/MindboxLoggerTests/MBLoggerUtilitiesFetcherTests.swift @@ -9,13 +9,13 @@ import Testing import Foundation @testable import MindboxLogger -/// Regression coverage for issue #705: `MBLoggerUtilitiesFetcher.applicationGroupIdentifier` +/// Regression coverage: `MBLoggerUtilitiesFetcher.applicationGroupIdentifier` /// must NEVER trap. It used to `fatalError` when the shared container was unavailable; it now /// returns `nil` so `LoggerDatabaseLoader` falls back to the app's local store. @Suite("MBLoggerUtilitiesFetcher App Group resolution") struct MBLoggerUtilitiesFetcherTests { - /// #705 core invariant: the getter must resolve WITHOUT trapping. It used to `fatalError` + /// Core invariant: the getter must resolve WITHOUT trapping. It used to `fatalError` /// when the shared container was unavailable; a `fatalError` would tear down the test /// runner, so this test reaching its assertion at all proves the trap is gone. @Test diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index 4ff3b780b..83cde0ef7 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -135,7 +135,7 @@ struct MBConfigurationTests { } } - @Test("operationsDomain accepts path prefix (MOBILE-258)") + @Test("operationsDomain accepts path prefix") func operationsDomainAcceptsPathPrefix() throws { let config = try MBConfiguration( endpoint: endpoint, diff --git a/MindboxTests/DI/AppGroupUnavailableTests.swift b/MindboxTests/DI/AppGroupUnavailableTests.swift index 2740848c6..e535e0d12 100644 --- a/MindboxTests/DI/AppGroupUnavailableTests.swift +++ b/MindboxTests/DI/AppGroupUnavailableTests.swift @@ -11,7 +11,7 @@ import CoreData import MindboxLogger @testable import Mindbox -/// #705 core-SDK fallback: an unavailable App Group must degrade to local storage, not crash. +/// Core-SDK fallback: an unavailable App Group must degrade to local storage, not crash. /// Serialized — the cases mutate global `MBInject` / `MBPersistenceStorage.defaults` / `MBPersistentContainer`. @Suite("App Group unavailable — core SDK fallback", .serialized) struct AppGroupUnavailableTests { @@ -25,7 +25,7 @@ struct AppGroupUnavailableTests { func getDeviceUUID(completion: @escaping (String) -> Void) { completion(UUID().uuidString) } } - /// #705: the getter used to `fatalError` on an unavailable container — a trap would tear down + /// The getter used to `fatalError` on an unavailable container — a trap would tear down /// the runner, so simply reaching the assertion proves it's gone. @Test func coreFetcherResolvesWithoutTrapping() { @@ -71,7 +71,7 @@ struct AppGroupUnavailableTests { } } -/// Storage-transition reporter (#705 follow-up): reports only when install state is in BOTH stores; read-only. +/// Storage-transition reporter: reports only when install state is in BOTH stores; read-only. @Suite("App Group storage-transition reporter") struct AppGroupStorageTransitionReporterTests { diff --git a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift index ba406fa24..cc3e80fd6 100644 --- a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift +++ b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift @@ -13,7 +13,7 @@ struct TransparentViewSyncOperationResponseTests { private let action = "syncOperation" private let requestId = UUID() - // MARK: - HTTP 200 + ValidationError body → .response with raw body (regression: MOBILE-164) + // MARK: - HTTP 200 + ValidationError body → .response with raw body (regression) @Test("HTTP 200 ValidationError body becomes .response with raw body string") func validationErrorBody_becomesResponseWithRawBody() throws { @@ -118,7 +118,7 @@ struct TransparentViewSyncOperationResponseTests { } } - // MARK: - Failure payloads: data contents only, no {type, data} envelope (MOBILE-197) + // MARK: - Failure payloads: data contents only, no {type, data} envelope private func decodedErrorPayload(_ outgoing: BridgeMessage) throws -> [String: Any] { #expect(outgoing.type == .error) diff --git a/MindboxTests/MindboxOperationsTests.swift b/MindboxTests/MindboxOperationsTests.swift index ac7993748..f141fbeff 100644 --- a/MindboxTests/MindboxOperationsTests.swift +++ b/MindboxTests/MindboxOperationsTests.swift @@ -10,7 +10,7 @@ import Testing import Foundation @testable import Mindbox -/// Contract tests for the public operations pipeline (MOBILE-208): the heavy work +/// Contract tests for the public operations pipeline: the heavy work /// runs on the serial eventQueue, yet every externally observable guarantee holds — /// call order equals DB write order, the body is snapshotted at call time, invalid /// input is dropped before the hop, executeSyncOperation completions always arrive diff --git a/MindboxTests/MindboxTests.swift b/MindboxTests/MindboxTests.swift index 0a8acc4ce..903a6b3f3 100644 --- a/MindboxTests/MindboxTests.swift +++ b/MindboxTests/MindboxTests.swift @@ -191,7 +191,7 @@ class MindboxTests: XCTestCase { // Functional validator cases live in OperationNameValidatorTests (Swift Testing). - // Regression (MOBILE-208): observe() used to invoke the host completion while + // Regression: observe() used to invoke the host completion while // holding observeSemaphore, so re-entering getDeviceUUID/getAPNSToken from inside // a completion deadlocked the controller queue. Now the completion runs after the // lock is released; on the old code this test fails by expectation timeout. diff --git a/MindboxTests/Network/MBEventRepositorySendRawTests.swift b/MindboxTests/Network/MBEventRepositorySendRawTests.swift index 4f22816b4..40fc48818 100644 --- a/MindboxTests/Network/MBEventRepositorySendRawTests.swift +++ b/MindboxTests/Network/MBEventRepositorySendRawTests.swift @@ -219,7 +219,7 @@ struct MBEventRepositorySendRawTests { #expect(isMain) } - // MARK: - send: completion always on the main queue (MOBILE-208 contract) + // MARK: - send: completion always on the main queue (public contract) @Test("send network-path completion is delivered on the main queue") func sendTyped_completion_onMainQueue() async throws { diff --git a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift index 10c750257..ec0cd5ae2 100644 --- a/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift +++ b/MindboxTests/Network/MBNetworkFetcherResponseHandlingTests.swift @@ -639,7 +639,7 @@ final class MBNetworkFetcherResponseHandlingTests: XCTestCase { waitForExpectations(timeout: 1) } - // MARK: - requestRaw: HTTP 200 + ValidationError body → raw Data success (regression: MOBILE-164) + // MARK: - requestRaw: HTTP 200 + ValidationError body → raw Data success (regression) func test_requestRaw_http200_statusValidationError_returnsRawData() throws { let fetcher = try makeFetcher() diff --git a/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift index e372192d3..b3e6249ce 100644 --- a/MindboxTests/Network/OperationResponseTests.swift +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -10,7 +10,7 @@ import Testing import Foundation @testable import Mindbox -/// Wire-contract tests for `OperationResponse` (MOBILE-303): the API returns promo +/// Wire-contract tests for `OperationResponse`: the API returns promo /// actions under the plural `promoActions` key, and the JSON re-encoded for the /// hybrid bridges (`createJSON`) must use the same wire keys as the decoder — /// otherwise a field silently vanishes between the API and the JS layer. @@ -96,7 +96,7 @@ struct OperationResponseTests { // The two productList shapes share one wire key on decode, but have always // re-encoded under their own property names (synthesized behavior). Locked - // down here so the MOBILE-303 encode(to:) rewrite changes nothing but promoActions. + // down here so the encode(to:) rewrite changes nothing but promoActions. @Test("An array productList re-encodes under the productList key") func productListArrayKeepsItsKey() throws { let response = try JSONDecoder().decode( @@ -121,7 +121,7 @@ struct OperationResponseTests { #expect(!dict.keys.contains("productList")) } - /// Shape of a production sync-operation response reported in MOBILE-303, fully + /// Shape of a production sync-operation response from a client report, fully /// anonymized: fractional seconds longer than the `.SSS` parse pattern (6 and 5 /// digits), sibling non-Utc date keys, `timeZoneMode`, and custom fields mixing /// booleans with strings. All values are synthetic; the key set and the date diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 5156a17f2..b38093203 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -164,7 +164,7 @@ struct OperationsURLRoutingTests { #expect(url?.path == "/v3/operations/async") } - // MARK: - Path prefix in operationsDomain (MOBILE-258) + // MARK: - Path prefix in operationsDomain @Test("operationsDomain with path prefix appends operation endpoint after the prefix") func pathPrefixAppendsEndpointAfterPrefix() throws { @@ -415,7 +415,7 @@ struct OperationsURLRoutingTests { ) } - @Test("Policy — saves value with path prefix in canonical form (MOBILE-258)") + @Test("Policy — saves value with path prefix in canonical form") func policySavesPathPrefixValue() { #expect( OperationsDomainConfigPolicy.action(for: "api-v2.letu.ru/api/mindbox-regular", currentlyStored: nil) From 9d480df9c1f7e3d58c40ec65e8496b9d9530935b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:03:17 +0000 Subject: [PATCH 22/23] Bump json from 2.20.0 to 2.21.2 Bumps [json](https://github.com/ruby/json) from 2.20.0 to 2.21.2. - [Release notes](https://github.com/ruby/json/releases) - [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md) - [Commits](https://github.com/ruby/json/compare/v2.20.0...v2.21.2) --- updated-dependencies: - dependency-name: json dependency-version: 2.21.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3f1deda51..e720eb256 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -245,7 +245,7 @@ GEM i18n (1.15.2) concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.20.0) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) @@ -421,7 +421,7 @@ CHECKSUMS httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 From 59c462e113c7f40beef3acf281fef944c95fd492 Mon Sep 17 00:00:00 2001 From: Anka Date: Mon, 10 Aug 2026 12:27:24 +0000 Subject: [PATCH 23/23] Bump SDK version from 2.15.2 to 2.15.3 --- Mindbox.podspec | 4 ++-- MindboxLogger.podspec | 2 +- MindboxNotifications.podspec | 2 +- SDKVersionProvider/SDKVersionConfig.xcconfig | 2 +- SDKVersionProvider/SDKVersionProvider.swift | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Mindbox.podspec b/Mindbox.podspec index dead59569..bb46631d6 100644 --- a/Mindbox.podspec +++ b/Mindbox.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "Mindbox" - spec.version = "2.15.2" + spec.version = "2.15.3" spec.summary = "SDK for integration with Mindbox" spec.description = "This library allows you to integrate data transfer to Mindbox Marketing Cloud" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" @@ -14,6 +14,6 @@ Pod::Spec.new do |spec| 'Mindbox' => ['Mindbox/**/*.xcassets', 'Mindbox/**/*.xcdatamodeld', 'Mindbox/**/*.xcprivacy'] } spec.swift_version = "5" - spec.dependency 'MindboxLogger', '2.15.2' + spec.dependency 'MindboxLogger', '2.15.3' end diff --git a/MindboxLogger.podspec b/MindboxLogger.podspec index af7bcd82c..0ae64b211 100644 --- a/MindboxLogger.podspec +++ b/MindboxLogger.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxLogger" - spec.version = "2.15.2" + spec.version = "2.15.3" spec.summary = "SDK for utilities to work with Mindbox" spec.description = "-" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" diff --git a/MindboxNotifications.podspec b/MindboxNotifications.podspec index 546412def..8dbd74297 100644 --- a/MindboxNotifications.podspec +++ b/MindboxNotifications.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = "MindboxNotifications" - spec.version = "2.15.2" + spec.version = "2.15.3" spec.summary = "SDK for integration notifications with Mindbox" spec.description = "This library allows you to integrate notifications and transfer them to Mindbox Marketing Cloud" spec.homepage = "https://github.com/mindbox-cloud/ios-sdk" diff --git a/SDKVersionProvider/SDKVersionConfig.xcconfig b/SDKVersionProvider/SDKVersionConfig.xcconfig index d166653e1..60e5ab7a0 100644 --- a/SDKVersionProvider/SDKVersionConfig.xcconfig +++ b/SDKVersionProvider/SDKVersionConfig.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 2.15.2 +MARKETING_VERSION = 2.15.3 diff --git a/SDKVersionProvider/SDKVersionProvider.swift b/SDKVersionProvider/SDKVersionProvider.swift index 7f33b151d..146d499e5 100644 --- a/SDKVersionProvider/SDKVersionProvider.swift +++ b/SDKVersionProvider/SDKVersionProvider.swift @@ -8,6 +8,6 @@ import Foundation public class SDKVersionProvider { - public static let sdkVersion = "2.15.2" + public static let sdkVersion = "2.15.3" }