Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions Mindbox.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

59 changes: 46 additions & 13 deletions Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,7 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol {
do {
let config = try jsonDecoder.decode(ConfigResponse.self, from: data)
configResponse = config
saveConfigToCache(data)
setupSettingsFromConfig(config.settings)
if let monitoring = config.monitoring, let logsManager = DI.inject(SDKLogsManagerProtocol.self) {
logsManager.sendLogs(logs: monitoring.logs.elements)
}
applyDownloadedConfig(config, rawData: data)
} catch {
applyConfigFromCache()
Logger.common(message: "Failed to parse downloaded config file. Error: \(error)", level: .error, category: .inAppMessages)
Expand All @@ -104,11 +100,25 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol {
applyConfigFromCache()
Logger.common(message: "Failed to download InApp configuration. Error: \(error.localizedDescription)", level: .error, category: .inAppMessages)
}

self.delegate?.didPreparedConfiguration()
sendNotification(with: configResponse?.settings?.slidingExpiration?.pushTokenKeepalive)
}

private func applyDownloadedConfig(_ config: ConfigResponse, rawData: Data) {
saveConfigToCache(rawData)
setupSettingsFromConfig(config.settings)
sendMonitoringLogsIfNeeded(config.monitoring)
}

private func sendMonitoringLogsIfNeeded(_ monitoring: Monitoring?) {
guard let monitoring = monitoring,
let logsManager = DI.inject(SDKLogsManagerProtocol.self) else {
return
}
logsManager.sendLogs(logs: monitoring.logs.elements)
}

private func applyConfigFromCache() {
guard var cachedConfig = self.fetchConfigFromCache() else {
Logger.common(message: "Failed to apply configuration from cache: No cached configuration found.")
Expand Down Expand Up @@ -149,21 +159,44 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol {
return
}

applySessionStorageSettings(settings)
featureToggleManager.applyFeatureToggles(settings.featureToggles)
persistOperationsDomain(from: settings.baseAddresses)
saveConfigSessionToCache(settings.slidingExpiration?.config)
}

private func applySessionStorageSettings(_ settings: Settings) {
let storage = SessionTemporaryStorage.shared

if let viewCategory = settings.operations?.viewCategory {
SessionTemporaryStorage.shared.viewCategoryOperation = viewCategory.systemName.lowercased()
storage.viewCategoryOperation = viewCategory.systemName.lowercased()
}

if let viewProduct = settings.operations?.viewProduct {
SessionTemporaryStorage.shared.viewProductOperation = viewProduct.systemName.lowercased()
storage.viewProductOperation = viewProduct.systemName.lowercased()
}

if let inappSettings = settings.inapp {
SessionTemporaryStorage.shared.inAppSettings = inappSettings
storage.inAppSettings = inappSettings
}
}

featureToggleManager.applyFeatureToggles(settings.featureToggles)

saveConfigSessionToCache(settings.slidingExpiration?.config)
private func persistOperationsDomain(from baseAddresses: Settings.BaseAddresses?) {
let current = persistenceStorage.operationsDomainFromConfig
let raw = baseAddresses?.operations

switch OperationsDomainConfigPolicy.action(for: raw, currentlyStored: current) {
case .keep:
break
case .clear:
persistenceStorage.operationsDomainFromConfig = nil
Logger.common(message: "[OperationsDomain] Cleared — config has no value.", level: .info, category: .inAppMessages)
case .save(let value):
persistenceStorage.operationsDomainFromConfig = value
Logger.common(message: "[OperationsDomain] Updated from config. [Value]: \(value)", level: .info, category: .inAppMessages)
case .rejected(let value):
Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(value)", level: .error, category: .inAppMessages)
}
}

private func createTTLValidationService() -> TTLValidationProtocol {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// OperationsDomainConfigPolicy.swift
// Mindbox
//
// Created by Sergei Semko on 4/27/26.
// Copyright © 2026 Mindbox. All rights reserved.
//

import Foundation

/// Decides save / clear / keep for the operations host coming from JSON config.
/// Extracted from `InAppConfigurationManager` so it can be unit-tested in isolation.
enum OperationsDomainConfigPolicy {

enum Action: Equatable {
case save(String)
/// Config explicitly cleared the value (null / missing / empty).
/// Caller falls back through the priority chain (init → domain).
case clear
/// No-op: nothing stored and nothing came, or canonicalized incoming
/// value already equals the stored one.
case keep
/// Incoming value is format-broken — previous value kept intact
/// (one bad push must not destroy a working config). Carries the raw
/// input so the caller can log it.
case rejected(String)
}

static func action(for raw: String?, currentlyStored: String?) -> Action {
guard let value = raw, !value.isEmpty else {
return currentlyStored == nil ? .keep : .clear
}

guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else {
return .rejected(value)
}

// Store canonical `scheme://host` so backend's choice of `http`/`https`
// is preserved across restarts and trailing slashes don't cause re-saves.
let normalized = HostNormalizer.toBaseURLString(value)
return normalized == currentlyStored ? .keep : .save(normalized)
}
}
28 changes: 28 additions & 0 deletions Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// BaseAddressesModel.swift
// Mindbox
//
// Created by Sergei Semko on 4/27/26.
// Copyright © 2026 Mindbox. All rights reserved.
//

import Foundation

extension Settings {
/// DTO for `settings.baseAddresses` in the mobile JSON config
/// (`/mobile/byendpoint/{endpointId}.json`).
struct BaseAddresses: Decodable, Equatable {
let operations: String?

enum CodingKeys: CodingKey {
case operations
}
}
}

extension Settings.BaseAddresses {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.operations = try? container.decodeIfPresent(String.self, forKey: .operations)
}
}
4 changes: 3 additions & 1 deletion Mindbox/InAppMessages/Models/Config/SettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ struct Settings: Decodable, Equatable {
let slidingExpiration: SlidingExpiration?
let inapp: InAppSettings?
let featureToggles: FeatureToggles?
let baseAddresses: BaseAddresses?

enum CodingKeys: CodingKey {
case operations, ttl, slidingExpiration, inapp, featureToggles
case operations, ttl, slidingExpiration, inapp, featureToggles, baseAddresses
}
}

Expand All @@ -28,5 +29,6 @@ extension Settings {
self.slidingExpiration = try? container.decodeIfPresent(SlidingExpiration.self, forKey: .slidingExpiration)
self.inapp = try? container.decodeIfPresent(InAppSettings.self, forKey: .inapp)
self.featureToggles = try? container.decodeIfPresent(FeatureToggles.self, forKey: .featureToggles)
self.baseAddresses = try? container.decodeIfPresent(BaseAddresses.self, forKey: .baseAddresses)
}
}
22 changes: 21 additions & 1 deletion Mindbox/MBConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import MindboxLogger
public struct MBConfiguration: Codable {
public let endpoint: String
public let domain: String
public var operationsDomain: String?
public var previousInstallationId: String?
public var previousDeviceUUID: String?
public var subscribeCustomerIfCreated: Bool
Expand All @@ -26,6 +27,8 @@ 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 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`.
Expand All @@ -36,6 +39,7 @@ public struct MBConfiguration: Codable {
public init(
endpoint: String,
domain: String,
operationsDomain: String? = nil,
previousInstallationId: String? = nil,
previousDeviceUUID: String? = nil,
subscribeCustomerIfCreated: Bool = false,
Expand All @@ -46,7 +50,7 @@ public struct MBConfiguration: Codable {
self.endpoint = endpoint
self.domain = domain

guard let url = URL(string: "https://" + domain), URLValidator(url: url).evaluate() else {
guard URLValidator.isValidHost(HostNormalizer.extractHost(domain)) else {
let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid domain. Domain is unreachable. [Domain]: \(domain)"))
Logger.error(error.asLoggerError())
throw error
Expand All @@ -58,6 +62,17 @@ public struct MBConfiguration: Codable {
throw error
}

if let operationsDomain = operationsDomain, !operationsDomain.isEmpty {
guard URLValidator.isValidHost(HostNormalizer.extractHost(operationsDomain)) else {
let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)"))
Logger.error(error.asLoggerError())
throw error
}
self.operationsDomain = operationsDomain
} else {
self.operationsDomain = nil
}

if let previousInstallationId = previousInstallationId, !previousInstallationId.isEmpty {
if UUID(uuidString: previousInstallationId) != nil && UDIDValidator(udid: previousInstallationId).evaluate() {
self.previousInstallationId = previousInstallationId
Expand Down Expand Up @@ -137,6 +152,7 @@ public struct MBConfiguration: Codable {
enum CodingKeys: String, CodingKey {
case endpoint
case domain
case operationsDomain
case previousInstallationId
case previousDeviceUUID
case subscribeCustomerIfCreated
Expand All @@ -148,6 +164,7 @@ public struct MBConfiguration: Codable {
let values = try decoder.container(keyedBy: CodingKeys.self)
let endpoint = try values.decode(String.self, forKey: .endpoint)
let domain = try values.decode(String.self, forKey: .domain)
let operationsDomain = try values.decodeIfPresent(String.self, forKey: .operationsDomain)
var previousInstallationId: String?
if let value = try? values.decode(String.self, forKey: .previousInstallationId) {
if !value.isEmpty {
Expand All @@ -166,6 +183,7 @@ public struct MBConfiguration: Codable {
try self.init(
endpoint: endpoint,
domain: domain,
operationsDomain: operationsDomain,
previousInstallationId: previousInstallationId,
previousDeviceUUID: previousDeviceUUID,
subscribeCustomerIfCreated: subscribeCustomerIfCreated,
Expand All @@ -191,6 +209,8 @@ struct ConfigValidation {

var changedState: ChangedState = .none

// `operationsDomain` is intentionally not diffed: changing it must not re-fire
// `installed`. The new value is picked up at request time via MBNetworkFetcher.
mutating func compare(_ lhs: MBConfiguration?, _ rhs: MBConfiguration?) {
if !(lhs?.domain == rhs?.domain && lhs?.endpoint == rhs?.endpoint) {
changedState = .rest
Expand Down
16 changes: 0 additions & 16 deletions Mindbox/MindboxLogger/SDKLogsRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,3 @@ struct SDKLogsRequest: Codable {
let requestId: String
let content: [String]
}

struct SDKLogsRoute: Route {
var method: HTTPMethod { .post }
var path: String { "/v3/operations/async/MobileSdk.Logs" }
var headers: HTTPHeaders? { nil }
var queryParameters: QueryParameters { .init() }
var body: Data?

func makeBasicQueryParameters(with wrapper: EventWrapper) -> QueryParameters {
["transactionId": wrapper.event.transactionId,
"deviceUUID": wrapper.deviceUUID,
"dateTimeOffset": wrapper.event.dateTimeOffset,
"operation": wrapper.event.type.rawValue,
"endpointId": wrapper.endpoint]
}
}
12 changes: 12 additions & 0 deletions Mindbox/Network/Abstract/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

import Foundation

enum RouteBaseURL {
case domain
/// Falls back to `domain` when no operations host is configured.
case operations
}

protocol Route {

var method: HTTPMethod { get }
Expand All @@ -19,4 +25,10 @@ protocol Route {
var queryParameters: QueryParameters { get }

var body: Data? { get }

var baseURLKind: RouteBaseURL { get }
}

extension Route {
var baseURLKind: RouteBaseURL { .domain }
}
47 changes: 47 additions & 0 deletions Mindbox/Network/Helpers/HostNormalizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// HostNormalizer.swift
// Mindbox
//
// Created by Sergei Semko on 4/27/26.
// Copyright © 2026 Mindbox. All rights reserved.
//

import Foundation

/// Scheme-aware normalization for `domain` / `operationsDomain` inputs.
/// Accepts `host`, `https://host`, `http://host`, with or without trailing slash.
enum HostNormalizer {

private static let httpsPrefix = "https://"
private static let httpPrefix = "http://"

/// Strips scheme (case-insensitive), whitespace, and trailing slashes.
static func extractHost(_ raw: String) -> String {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if value.range(of: httpsPrefix, options: [.caseInsensitive, .anchored]) != nil {
value = String(value.dropFirst(httpsPrefix.count))
} else if value.range(of: httpPrefix, options: [.caseInsensitive, .anchored]) != nil {
value = String(value.dropFirst(httpPrefix.count))
}
while value.hasSuffix("/") {
value.removeLast()
}
return value
}

/// Preserves an existing scheme, otherwise prepends `https://`.
static func toBaseURLString(_ raw: String) -> String {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
while value.hasSuffix("/") { value.removeLast() }
if hasSchemePrefix(value) {
return value
}
return httpsPrefix + value
}

private static func hasSchemePrefix(_ value: String) -> Bool {
let options: String.CompareOptions = [.caseInsensitive, .anchored]
return value.range(of: httpsPrefix, options: options) != nil
|| value.range(of: httpPrefix, options: options) != nil
}
}
Loading